From 03e38e76b008d402762249f739e8f0ef64e6acfd Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Oct 2026 23:20:28 -0600 Subject: [PATCH 1/3] feat(i18n): add next-intl with cookie/Accept-Language locale and a Japanese catalog Co-Authored-By: Claude Opus 5.5 --- .storybook/preview.tsx | 29 +- jest.config.cjs | 2 +- jest.setup.ts | 41 + next.config.js | 6 +- next.config.test.ts | 1 + package-lock.json | 765 +++++++++++++++++- package.json | 1 + src/app/layout.tsx | 21 +- src/components/layout/Footer.tsx | 44 +- .../layout/LocaleSwitcher.stories.tsx | 22 + src/components/layout/LocaleSwitcher.tsx | 35 + src/components/layout/NavLinks.tsx | 6 +- src/global.d.ts | 9 + src/i18n/locales.test.ts | 35 + src/i18n/locales.ts | 38 + src/i18n/messages/en.json | 39 + src/i18n/messages/ja.json | 39 + src/i18n/request.ts | 17 + 18 files changed, 1113 insertions(+), 37 deletions(-) create mode 100644 src/components/layout/LocaleSwitcher.stories.tsx create mode 100644 src/components/layout/LocaleSwitcher.tsx create mode 100644 src/global.d.ts create mode 100644 src/i18n/locales.test.ts create mode 100644 src/i18n/locales.ts create mode 100644 src/i18n/messages/en.json create mode 100644 src/i18n/messages/ja.json create mode 100644 src/i18n/request.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f3e914696..d4691594a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -3,6 +3,9 @@ import type { Preview } from "@storybook/nextjs-vite"; import { sb } from "storybook/test"; import { Theme } from "@radix-ui/themes"; import { IBM_Plex_Sans } from "next/font/google"; +import { NextIntlClientProvider } from "next-intl"; +import en from "../src/i18n/messages/en.json"; +import ja from "../src/i18n/messages/ja.json"; import "@radix-ui/themes/styles.css"; import "../src/styles/globals.css"; @@ -101,6 +104,19 @@ const preview: Preview = { }, }, globalTypes: { + locale: { + description: "Display language", + defaultValue: "en", + toolbar: { + title: "Language", + icon: "globe", + items: [ + { value: "en", title: "English" }, + { value: "ja", title: "日本語" }, + ], + dynamicTitle: true, + }, + }, appearance: { description: "Radix theme appearance", defaultValue: "light", @@ -115,6 +131,7 @@ const preview: Preview = { decorators: [ (Story, context) => { const appearance = context.globals.appearance as "light" | "dark"; + const locale = context.globals.locale === "ja" ? "ja" : "en"; // .radix-themes is min-height:100vh -- right for one story to a frame, // but a docs page stacks them into screens of whitespace. const fillsFrame = context.viewMode !== "docs"; @@ -124,9 +141,15 @@ const preview: Preview = { appearance={appearance} style={fillsFrame ? undefined : { minHeight: 0 }} > -
- -
+ +
+ +
+
); }, diff --git a/jest.config.cjs b/jest.config.cjs index a578bbec8..7ff2beb44 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -20,7 +20,7 @@ const customJestConfig = { "^.+\\.(js|jsx|ts|tsx|mjs)$": ["babel-jest", { presets: ["next/babel"] }], }, transformIgnorePatterns: [ - "/node_modules/(?!(@storybook/[^/]+|storybook|react-markdown|bright|vfile|vfile-.*|unist-.*|unified|bail|is-plain-obj|trough|remark-.*|devlop|markdown-table|longest-streak|mdast-util-.*|micromark.*|decode-named-character-reference|character-entities|property-information|hast-util-.*|html-url-attributes|style-to-js|estree-util-is-identifier-name|zwitch|ccount|escape-string-regexp|trim-lines|space-separated-tokens|comma-separated-tokens|pretty-bytes|aws-sdk-client-mock|sinon|jose)/)", + "/node_modules/(?!(@storybook/[^/]+|storybook|react-markdown|bright|vfile|vfile-.*|unist-.*|unified|bail|is-plain-obj|trough|remark-.*|devlop|markdown-table|longest-streak|mdast-util-.*|micromark.*|decode-named-character-reference|character-entities|property-information|hast-util-.*|html-url-attributes|style-to-js|estree-util-is-identifier-name|zwitch|ccount|escape-string-regexp|trim-lines|space-separated-tokens|comma-separated-tokens|pretty-bytes|aws-sdk-client-mock|sinon|jose|next-intl|use-intl|@formatjs|intl-messageformat|icu-minify|@schummar)/)", ], collectCoverageFrom: [ "src/**/*.{js,jsx,ts,tsx}", diff --git a/jest.setup.ts b/jest.setup.ts index eaa067809..22866ca91 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -13,6 +13,47 @@ jest.mock("next/navigation", () => ({ usePathname: () => "/", })); +// Components read their strings through next-intl, which needs a provider on +// the client and a request on the server. Tests have neither, so both resolve +// against the real English catalog, and assertions match the text users see. +const intl = () => { + const { createTranslator, createFormatter } = jest.requireActual< + typeof import("use-intl/core") + >("use-intl/core"); + const messages = jest.requireActual("@/i18n/messages/en.json") as Record< + string, + never + >; + const config = { locale: "en", timeZone: "UTC", messages } as const; + return { + t: (namespace?: string) => + createTranslator({ ...config, namespace: namespace as never }), + format: () => createFormatter(config), + }; +}; +jest.mock("next-intl", () => { + const { t, format } = intl(); + return { + useTranslations: t, + useFormatter: format, + useLocale: () => "en", + useTimeZone: () => "UTC", + useNow: () => new Date(), + NextIntlClientProvider: ({ children }: { children: unknown }) => children, + }; +}); +jest.mock("next-intl/server", () => { + const { t, format } = intl(); + return { + getTranslations: async ( + arg?: string | { namespace?: string } + ) => t(typeof arg === "string" ? arg : arg?.namespace), + getFormatter: async () => format(), + getLocale: async () => "en", + getRequestConfig: (fn: unknown) => fn, + }; +}); + // @ts-ignore global.Request = Request; // @ts-ignore diff --git a/next.config.js b/next.config.js index f23eb2c6a..fe26b70b7 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,7 @@ +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); + /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, @@ -42,4 +46,4 @@ const nextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/next.config.test.ts b/next.config.test.ts index f6ae8a347..73e709361 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -1,3 +1,4 @@ +/** @jest-environment node */ import nextConfig from "./next.config.js"; describe("next.config headers()", () => { diff --git a/package-lock.json b/package-lock.json index 7ba7dfaa4..7b410cb61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "lucide-react": "^0.483.0", "md5": "^2.3.0", "next": "15.5.9", + "next-intl": "^4.14.9", "next-themes": "^0.4.4", "nextjs-toploader": "^3.8.16", "react": "^18.2.0", @@ -87,7 +88,7 @@ "ts-node": "^10.9.2", "typescript": "^5.0.0", "undici": "^7.10.0", - "vite": "latest", + "vite": "^8.2.2", "web-streams-polyfill": "^4.1.0" } }, @@ -4994,6 +4995,31 @@ "win32" ] }, + "node_modules/@eloqnt/config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@eloqnt/config/-/config-0.1.1.tgz", + "integrity": "sha512-YRDigSaiJEzKDfzTth5kG+L/PKfB3mo8h7yNzpXExPfvQmcD05WoZT57PdhJE0jI+Vva6eb0OCU8hnHHgdME3g==", + "license": "MIT" + }, + "node_modules/@eloqnt/format-json": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@eloqnt/format-json/-/format-json-0.1.0.tgz", + "integrity": "sha512-4Pkb+HyzgWNJXoU48aJhUyNZSjZvMGooPCNG1ZFmBm55xTiNNPQCNXH66Ob6sXS6RvbWZrRpCf+Uo/Jz9K9g+A==", + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.0" + } + }, + "node_modules/@eloqnt/format-po": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@eloqnt/format-po/-/format-po-0.1.1.tgz", + "integrity": "sha512-aLtmIoqXGEaeTT0RcsLN8sZ3aIrsT/c/dVs28zrLhDjO3+vA3KNP+r98N0xq8w/k36+/GIUt4uZNbqcA71GVWw==", + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.1", + "po-parser": "^2.2.0" + } + }, "node_modules/@emnapi/core": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", @@ -7878,6 +7904,292 @@ "win32" ] }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -10022,6 +10334,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://registry.npmjs.org/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -11099,6 +11417,204 @@ "node": ">=6" } }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.13.tgz", + "integrity": "sha512-JrMH2h7ohd4mOIneNRx/diqTmCDSdWfp9uOdQ8VJ2V2CA7jOJ1jWb+qp8RoZLqgiVmwDDC7eySKB1kbvZ+jVXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.13.tgz", + "integrity": "sha512-HauZMRxpOImRxgY6GXjw5gbD8ts2NcPZuLMM8htY8Mic7j1Izt/lxkXrKYUwqyU56YLwQZ4dHRT+6jFHkztcag==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.13.tgz", + "integrity": "sha512-tUSojGIythnHJKrElDM9OvuhBnK+FF39eM9iIBgj9kdfbAm6PhAM7dKbaSDcnX2gOLG2slkasjD0Z8m/YUlzOA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.13.tgz", + "integrity": "sha512-qS3bk/ezRrLXGn1b764S/EG3/oKj6uRhnTNRAALpnuu8d9I0YwSiUkQghnX7lOH9X/yRr+qrFI3R0wAruO47Tg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.13.tgz", + "integrity": "sha512-aeoYoG6fQsOS3mFOuTKvnJo1aaaxYNI0aRRlhdHaGP4q1YQx4XOc5Hkd35IFduWCBG3eZl3fPTjRuYvxpl7eTA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.13.tgz", + "integrity": "sha512-eEFoueZXWZ2S1lsqL8Ve2C8RjhzH9+Ktj8U4IGTUQL6CNG04+VhQiYQWJOf5xfVBU6I+PbH80yvsYegqZOOSZQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.13.tgz", + "integrity": "sha512-K0q1ReVXL3d5bzBgZxS+yJq5q2xRjg4k2XXGj9yiCEpE5ZqJGQ4Gtk1sRrk8W2lo3KY0DCJD3Myt6x2SMvGArQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.13.tgz", + "integrity": "sha512-Qvpihw30irhIjy2f91BR1pamcJBCgf8hmr9XWenztAX1YLkneRkkTB/PFGWQubhaBqQhY2uZKViMj/xV61uMBQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.13.tgz", + "integrity": "sha512-iCGxHbh3Cq5vqRvCPlw3XkkDz4RlkPSyQwfFKbOKTMj5tiEuojY/rLmf4BMfI9Z8yPJTf9VB07t3NbKInoC97g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.13.tgz", + "integrity": "sha512-iOabn6cd+hg3cnoKlAC8j3Yufv8nQHBVU5OIDX8dCPA95PjAZ0B0TlDn9sWivk8ezo/cLJux7p9n0B9vRej+1Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.13.tgz", + "integrity": "sha512-+1b/3xBNuVbpnJIZUCAaSdscOPYRWq85eohKx44y/3qbPp56UMiCJWBiWtyr7K5F1nf1JENXm2IBSEJaLgWq6Q==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.13.tgz", + "integrity": "sha512-6kEJhYshk7OVoVIWryiHqjCNuwZeOlPTS6et+bKV/WhtHs8fLA01wyTLrQGi+4ultgRe/o0M4b25K/KKdvl0dQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -11108,6 +11624,15 @@ "tslib": "^2.8.0" } }, + "node_modules/@swc/types": { + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.5", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.5.tgz", @@ -14122,6 +14647,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -14752,7 +15290,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -17054,6 +17591,36 @@ "node": ">=0.10.0" } }, + "node_modules/icu-minify": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.14.9.tgz", + "integrity": "sha512-PmO+fNnC3K3mfPKTnm3RrPERrhQZnjm1AlpyZQ0ShbXi8Y/kYZ3YOde8TEjCLz9k3P2DmzkBn8lykGrCAi1Ajw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, + "node_modules/icu-minify/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.21.tgz", + "integrity": "sha512-Vnl7IOSyMRaP4Pjv48etkT2nlcIwzh4QWIa3hewzGlZZPBbft+TbMir4MijpDViOLN/1hvkGC10oYMuilsG+fw==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.12" + } + }, + "node_modules/icu-minify/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.12.tgz", + "integrity": "sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==", + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -17495,7 +18062,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -17560,7 +18126,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -22048,6 +22613,22 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/next": { "version": "15.5.9", "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", @@ -22100,6 +22681,112 @@ } } }, + "node_modules/next-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.14.9.tgz", + "integrity": "sha512-uauRtUC+TrYNA1OL/QcwL9irndLrI9jK3Wdaa9W0B5AeKh9cqVdNeT0IWgLnhBqBRrVwppVdI0M+C1+JdGPQ3w==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@eloqnt/config": "^0.1.1", + "@eloqnt/format-json": "^0.1.0", + "@eloqnt/format-po": "^0.1.1", + "@formatjs/intl-localematcher": "^0.8.1", + "@parcel/watcher": "^2.4.1", + "@swc/core": "~1.16.0", + "icu-minify": "^4.14.9", + "negotiator": "^1.0.0", + "next-intl-swc-plugin-extractor": "4.14.9", + "use-intl": "^4.14.9" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/next-intl-swc-plugin-extractor": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.14.9.tgz", + "integrity": "sha512-8jAkmDwQJo6RDavBHm9QRl1YTn2WXf+rI4SVhMaZxZcxopnwZic6fKTXOoNIo3kzC+a6vJySkOkXmHTVFLAYtg==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/next-intl/node_modules/@formatjs/intl-localematcher": { + "version": "0.8.14", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.14.tgz", + "integrity": "sha512-8O7V38QmbB11+ryJ3KM/znrzXtiqgpXzqNoqDsndHZZ5/zDKTTL+cf5F0mzXkQEwhx8IlWnqYvEovf2S5EwLTw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7" + } + }, + "node_modules/next-intl/node_modules/@swc/core": { + "version": "1.16.13", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.13.tgz", + "integrity": "sha512-k4uUsIMS0IwOKVsrQs48IZK75O4roV3FnuJEdL7ES+DFESftdgvz73QBqN3VUAbUmUr78We4tzG0kgYj36M22Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.28" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.16.13", + "@swc/core-darwin-x64": "1.16.13", + "@swc/core-linux-arm-gnueabihf": "1.16.13", + "@swc/core-linux-arm64-gnu": "1.16.13", + "@swc/core-linux-arm64-musl": "1.16.13", + "@swc/core-linux-ppc64-gnu": "1.16.13", + "@swc/core-linux-s390x-gnu": "1.16.13", + "@swc/core-linux-x64-gnu": "1.16.13", + "@swc/core-linux-x64-musl": "1.16.13", + "@swc/core-win32-arm64-msvc": "1.16.13", + "@swc/core-win32-ia32-msvc": "1.16.13", + "@swc/core-win32-x64-msvc": "1.16.13" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next-themes": { "version": "0.4.6", "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", @@ -22152,6 +22839,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -22773,6 +23466,12 @@ "node": ">=8" } }, + "node_modules/po-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.2.0.tgz", + "integrity": "sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==", + "license": "MIT" + }, "node_modules/point-in-polygon-hao": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz", @@ -27257,6 +27956,64 @@ } } }, + "node_modules/use-intl": { + "version": "4.14.9", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.14.9.tgz", + "integrity": "sha512-37ktvdCCz+6JY2VFPl/GV69Ds4ahiUT2c2xEZZRZYxdgGLxFsUbdxzjQVRm3YMIOUU0VPs6zvlifb4Yjk4IxIg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^3.1.0", + "@schummar/icu-type-parser": "1.21.5", + "icu-minify": "^4.14.9", + "intl-messageformat": "^11.1.0" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-intl/node_modules/@formatjs/fast-memoize": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.7.tgz", + "integrity": "sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==", + "license": "MIT" + }, + "node_modules/use-intl/node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.18.tgz", + "integrity": "sha512-wX1efcL8d7K5QapOTRsP/OLTA/kpwe4LwqWyNxDNg5iRs8i9jJbCyxB5P8jc4KOW5oiLzz2DJr+BRY5xMV9+bA==", + "license": "MIT", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.11" + } + }, + "node_modules/use-intl/node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.11.tgz", + "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", + "license": "MIT" + }, + "node_modules/use-intl/node_modules/intl-messageformat": { + "version": "11.2.15", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.15.tgz", + "integrity": "sha512-W4VNytsXyWgW5lvVchmsb9xgA5rSKqoflu3/IQ9UCtB0z4R+Bx9MoEkwFT/+30RCl/KzUoH0QGGe9lppF5SuvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/fast-memoize": "3.1.7", + "@formatjs/icu-messageformat-parser": "3.5.18" + } + }, "node_modules/use-sidecar": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", diff --git a/package.json b/package.json index c4b3a261c..365de362a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "lucide-react": "^0.483.0", "md5": "^2.3.0", "next": "15.5.9", + "next-intl": "^4.14.9", "next-themes": "^0.4.4", "nextjs-toploader": "^3.8.16", "react": "^18.2.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1bbf487e7..4e4e8e9e5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,8 @@ import { IBM_Plex_Sans } from "next/font/google"; import { S3CredentialsProvider, UploadProvider } from "@/components"; import { metadata } from "./metadata"; import { CONFIG } from "@/lib/config"; +import { NextIntlClientProvider } from "next-intl"; +import { getLocale } from "next-intl/server"; const ibmPlexSans = IBM_Plex_Sans({ subsets: ["latin"], weight: ["400", "500", "600", "700"], @@ -20,8 +22,9 @@ interface RootLayoutProps { } export default async function RootLayout({ children }: RootLayoutProps) { + const locale = await getLocale(); return ( - + - - - - {children} - - - + + + + + {children} + + + + diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 2615c7e5e..ca7bdaa51 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -9,9 +9,11 @@ import { } from "@radix-ui/themes"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { MonoText } from "@/components/core"; import { homeUrl, productListUrl } from "@/lib/urls"; import { Logo } from "./Logo"; +import { LocaleSwitcher } from "./LocaleSwitcher"; // ponytail: at 0.75rem these links are ~15px tall, under the 24px WCAG 2.2 // minimum. inline-block is what makes the padding count toward the hit box — @@ -20,6 +22,7 @@ const tapTarget = { display: "inline-block", paddingBlock: "5px" } as const; export function Footer() { const pathname = usePathname(); + const t = useTranslations("Footer"); return ( @@ -38,7 +41,7 @@ export function Footer() { - ABOUT US + {t("aboutUs")}
  • - Docs + {t("docs")}
  • @@ -90,7 +93,7 @@ export function Footer() { - Home + {t("home")} @@ -98,7 +101,7 @@ export function Footer() { - All Products + {t("allProducts")} @@ -108,7 +111,7 @@ export function Footer() { color="gray" underline="always" > - Docs + {t("docs")} @@ -122,19 +125,24 @@ export function Footer() { width: "100%", }} /> - - Source Cooperative is a{" "} - - - Radiant Earth - - {" "} - project - + + + {t.rich("attribution", { + org: (chunks) => ( + + + {chunks} + + + ), + })} + + + diff --git a/src/components/layout/LocaleSwitcher.stories.tsx b/src/components/layout/LocaleSwitcher.stories.tsx new file mode 100644 index 000000000..8d622f8d9 --- /dev/null +++ b/src/components/layout/LocaleSwitcher.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { LocaleSwitcher } from "./LocaleSwitcher"; + +/** + * The language picker in the footer. Each language is listed in its own + * script, so a reader who can't read the current page can still find theirs. + * + * The choice is remembered in a cookie; without one, the site follows the + * browser's language preference. Switch the story's language from the + * toolbar to see the trigger follow it. + */ +const meta = { + title: "Features/App shell/LocaleSwitcher", + component: LocaleSwitcher, + parameters: { layout: "padded" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** Shows the language the page is rendered in. */ +export const Default: Story = {}; diff --git a/src/components/layout/LocaleSwitcher.tsx b/src/components/layout/LocaleSwitcher.tsx new file mode 100644 index 000000000..e7b9311ba --- /dev/null +++ b/src/components/layout/LocaleSwitcher.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Select } from "@radix-ui/themes"; +import { useLocale, useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { LOCALE_COOKIE, LOCALE_NAMES, LOCALES } from "@/i18n/locales"; + +/** + * Pins the display language. The choice is a cookie rather than a URL prefix, + * so writing it client-side and refreshing re-renders the same page in the new + * language without a server action. + */ +export function LocaleSwitcher() { + const t = useTranslations("LocaleSwitcher"); + const locale = useLocale(); + const router = useRouter(); + + const choose = (next: string) => { + document.cookie = `${LOCALE_COOKIE}=${next}; path=/; max-age=31536000; samesite=lax`; + router.refresh(); + }; + + return ( + + + + {LOCALES.map((l) => ( + + {LOCALE_NAMES[l]} + + ))} + + + ); +} diff --git a/src/components/layout/NavLinks.tsx b/src/components/layout/NavLinks.tsx index a046f0297..f6536fb76 100644 --- a/src/components/layout/NavLinks.tsx +++ b/src/components/layout/NavLinks.tsx @@ -1,5 +1,6 @@ "use client"; import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; import { Link, Separator } from "@radix-ui/themes"; import NextLink from "next/link"; import styles from "./Navigation.module.css"; @@ -12,15 +13,16 @@ import { docsUrl, homeUrl, productListUrl } from "@/lib"; */ export function NavLinks({ divider = false }: { divider?: boolean }) { const pathname = usePathname(); + const t = useTranslations("NavLinks"); if (pathname === homeUrl()) return null; return ( <> - Products + {t("products")} - Docs + {t("docs")} {divider && ( diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 000000000..024edf525 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,9 @@ +import type { Locale } from "@/i18n/locales"; +import type messages from "@/i18n/messages/en.json"; + +declare module "next-intl" { + interface AppConfig { + Locale: Locale; + Messages: typeof messages; + } +} diff --git a/src/i18n/locales.test.ts b/src/i18n/locales.test.ts new file mode 100644 index 000000000..b4ba35f84 --- /dev/null +++ b/src/i18n/locales.test.ts @@ -0,0 +1,35 @@ +import { negotiateLocale } from "./locales"; +import en from "./messages/en.json"; +import ja from "./messages/ja.json"; + +describe("negotiateLocale", () => { + it("prefers the cookie over the browser", () => { + expect(negotiateLocale("en", "ja-JP,ja;q=0.9")).toBe("en"); + }); + + it("ignores an unsupported cookie", () => { + expect(negotiateLocale("xx", "ja-JP")).toBe("ja"); + }); + + it("takes the highest-weighted supported language", () => { + expect(negotiateLocale(undefined, "fr;q=1,en;q=0.5,ja;q=0.8")).toBe("ja"); + }); + + it("falls back to English", () => { + expect(negotiateLocale(undefined, "fr-FR,de;q=0.9")).toBe("en"); + expect(negotiateLocale(undefined, null)).toBe("en"); + }); +}); + +// Every key in en.json is a string the UI renders; a key missing from a +// translation renders as its raw path instead. +const keys = (obj: object, prefix = ""): string[] => + Object.entries(obj).flatMap(([k, v]) => + typeof v === "object" ? keys(v, `${prefix}${k}.`) : [`${prefix}${k}`] + ); + +describe("messages", () => { + it("ja has exactly the keys en has", () => { + expect(keys(ja).sort()).toEqual(keys(en).sort()); + }); +}); diff --git a/src/i18n/locales.ts b/src/i18n/locales.ts new file mode 100644 index 000000000..3a0b668d5 --- /dev/null +++ b/src/i18n/locales.ts @@ -0,0 +1,38 @@ +export const LOCALES = ["en", "ja"] as const; +export type Locale = (typeof LOCALES)[number]; +export const DEFAULT_LOCALE: Locale = "en"; + +/** next-intl's default cookie name; the switcher writes it, request.ts reads it. */ +export const LOCALE_COOKIE = "NEXT_LOCALE"; + +/** Each language named in itself, so a reader can find theirs in the switcher. */ +export const LOCALE_NAMES: Record = { + en: "English", + ja: "日本語", +}; + +const isLocale = (value: string | undefined): value is Locale => + LOCALES.includes(value as Locale); + +/** + * The locale to render: an explicit choice from the switcher's cookie wins, + * then the first supported language in the browser's Accept-Language, then + * English. Locales live outside the URL because a `/ja` prefix would collide + * with account ids, which own the top level of the path. + */ +export function negotiateLocale( + cookie: string | undefined, + acceptLanguage: string | null | undefined +): Locale { + if (isLocale(cookie)) return cookie; + const preferred = (acceptLanguage ?? "") + .split(",") + .map((part) => { + const [tag, q] = part.trim().split(";q="); + return { base: tag.split("-")[0].toLowerCase(), q: q ? Number(q) : 1 }; + }) + .filter(({ q }) => q > 0) + .sort((a, b) => b.q - a.q) + .find(({ base }) => isLocale(base)); + return (preferred?.base as Locale) ?? DEFAULT_LOCALE; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json new file mode 100644 index 000000000..1d4004986 --- /dev/null +++ b/src/i18n/messages/en.json @@ -0,0 +1,39 @@ +{ + "Common": { + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "delete": "Delete", + "deleting": "Deleting…", + "remove": "Remove", + "edit": "Edit", + "create": "Create", + "close": "Close", + "back": "Back", + "next": "Next", + "previous": "Previous", + "confirm": "Confirm", + "loading": "Loading…", + "search": "Search", + "copy": "Copy", + "copied": "Copied", + "yes": "Yes", + "no": "No", + "none": "None", + "unknownError": "Something went wrong. Please try again." + }, + "Footer": { + "aboutUs": "ABOUT US", + "docs": "Docs", + "home": "Home", + "allProducts": "All Products", + "attribution": "Source Cooperative is a Radiant Earth project" + }, + "LocaleSwitcher": { + "label": "Language" + }, + "NavLinks": { + "products": "Products", + "docs": "Docs" + } +} diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json new file mode 100644 index 000000000..760216461 --- /dev/null +++ b/src/i18n/messages/ja.json @@ -0,0 +1,39 @@ +{ + "Common": { + "save": "保存", + "saving": "保存中…", + "cancel": "キャンセル", + "delete": "削除", + "deleting": "削除中…", + "remove": "削除", + "edit": "編集", + "create": "作成", + "close": "閉じる", + "back": "戻る", + "next": "次へ", + "previous": "前へ", + "confirm": "確認", + "loading": "読み込み中…", + "search": "検索", + "copy": "コピー", + "copied": "コピーしました", + "yes": "はい", + "no": "いいえ", + "none": "なし", + "unknownError": "問題が発生しました。もう一度お試しください。" + }, + "Footer": { + "aboutUs": "概要", + "docs": "ドキュメント", + "home": "ホーム", + "allProducts": "すべてのプロダクト", + "attribution": "Source Cooperative は Radiant Earth のプロジェクトです" + }, + "LocaleSwitcher": { + "label": "言語" + }, + "NavLinks": { + "products": "プロダクト", + "docs": "ドキュメント" + } +} diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 000000000..458edea2a --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,17 @@ +import { getRequestConfig } from "next-intl/server"; +import { cookies, headers } from "next/headers"; +import { LOCALE_COOKIE, negotiateLocale } from "./locales"; + +export default getRequestConfig(async () => { + const locale = negotiateLocale( + (await cookies()).get(LOCALE_COOKIE)?.value, + (await headers()).get("accept-language") + ); + return { + locale, + // Server-rendered dates must match the client's on hydration, so both + // sides format in one fixed zone rather than each in its own. + timeZone: "UTC", + messages: (await import(`./messages/${locale}.json`)).default, + }; +}); From ae3fd167da34d93afd470d9b7476b5ec212d5171 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Thu, 8 Oct 2026 23:34:01 -0600 Subject: [PATCH 2/3] feat(i18n): translate the app's UI strings to Japanese Moves every user-visible string in the app shell, product pages, object browser, uploader, analytics, settings, service accounts, data connections, admin tools and server-action responses into next-intl catalogs, with a Japanese translation for each. Dates and numbers that were pinned to en-US now format in the reader's locale. User content, brand names, API responses and log messages stay as they are. Co-Authored-By: Claude Opus 5.5 --- .../(product)/-/analytics/page.tsx | 23 +- .../(product)/[[...path]]/@preview/page.tsx | 12 +- .../(product)/[[...path]]/layout.tsx | 7 +- .../(product)/[[...path]]/page.tsx | 13 +- .../(app)/[account_id]/[product_id]/error.tsx | 13 +- .../[account_id]/[product_id]/loading.tsx | 6 +- .../[account_id]/[product_id]/not-found.tsx | 37 +- src/app/(app)/[account_id]/not-found.tsx | 9 +- .../[account_id]/organization/new/page.tsx | 10 +- src/app/(app)/admin/analytics/page.tsx | 149 +- .../[data_connection_id]/page.tsx | 17 +- .../admin/data-connections/create/page.tsx | 12 +- src/app/(app)/admin/data-connections/page.tsx | 13 +- src/app/(app)/admin/layout.tsx | 7 +- src/app/(app)/admin/user-lookup/page.tsx | 13 +- .../[data_connection_id]/page.tsx | 17 +- .../data-connections/create/page.tsx | 13 +- .../[account_id]/data-connections/page.tsx | 15 +- .../edit/account/[account_id]/layout.tsx | 21 +- .../account/[account_id]/memberships/page.tsx | 14 +- .../account/[account_id]/permissions/page.tsx | 9 +- .../[account_id]/profile-picture/page.tsx | 16 +- .../account/[account_id]/profile/page.tsx | 11 +- .../[service_account_id]/page.tsx | 9 +- .../service-accounts/create/page.tsx | 11 +- .../[account_id]/service-accounts/page.tsx | 13 +- .../[product_id]/details/page.tsx | 9 +- .../[account_id]/[product_id]/layout.tsx | 10 +- .../[product_id]/memberships/page.tsx | 33 +- .../[account_id]/[product_id]/not-found.tsx | 33 +- src/app/(app)/onboarding/page.tsx | 16 +- src/app/(app)/products/loading.tsx | 4 +- src/app/(app)/products/new/page.tsx | 18 +- src/app/(app)/products/page.test.tsx | 12 +- src/app/(app)/products/page.tsx | 46 +- src/app/(marketing)/CaseStudyCarousel.tsx | 8 +- src/app/(marketing)/HeroGlobe.tsx | 4 +- src/app/(marketing)/page.tsx | 102 +- src/app/layout.tsx | 4 +- src/app/metadata.ts | 10 + src/app/page.test.tsx | 12 +- src/components/core/AccountInfoHoverCard.tsx | 4 +- src/components/core/AccountSearchInput.tsx | 6 +- src/components/core/ConditionalGroup.tsx | 4 +- src/components/core/CopyToClipboard.tsx | 6 +- src/components/core/DangerZone.tsx | 4 +- src/components/core/DynamicForm.tsx | 3 +- src/components/core/EditButton.tsx | 8 +- src/components/core/Field.tsx | 4 +- src/components/core/FormActions.tsx | 6 +- src/components/core/LoginButton.tsx | 6 +- src/components/core/LoginRequired.tsx | 4 +- src/components/core/SecretField.tsx | 8 +- src/components/core/StatusPage.tsx | 41 +- src/components/display/BreadcrumbNav.tsx | 6 +- src/components/display/DateText.tsx | 33 +- .../features/accounts/AccountFlagsForm.tsx | 30 +- .../features/accounts/InviteMemberForm.tsx | 34 +- .../features/admin/AdminUserLookup.tsx | 25 +- .../features/admin/AdminUserSearchField.tsx | 6 +- src/components/features/admin/tools.ts | 13 +- .../analytics/AdminBreakdownChart.tsx | 129 +- .../features/analytics/AdminFiltersForm.tsx | 38 +- .../analytics/ProductAnalyticsView.tsx | 73 +- .../features/analytics/ProductTabs.tsx | 10 +- .../features/analytics/UsageCard.tsx | 10 +- .../features/analytics/UsageCardSkeleton.tsx | 4 +- .../features/analytics/UsagePanel.stories.tsx | 12 +- .../features/analytics/UsagePanel.tsx | 29 +- src/components/features/analytics/panels.tsx | 80 +- src/components/features/analytics/style.ts | 38 +- .../auth/EmailVerificationCallout.tsx | 12 +- .../data-connections/ConnectionUsage.tsx | 23 +- .../data-connections/ConnectionsEmpty.tsx | 4 +- .../data-connections/DataConnectionForm.tsx | 261 ++-- .../data-connections/DataConnectionsList.tsx | 15 +- .../DeleteConnectionControl.tsx | 12 +- .../DeleteDataConnectionButton.tsx | 14 +- .../ProductMirrorsManager.tsx | 64 +- src/components/features/globe/LiveGlobe.tsx | 6 +- .../features/keyboard/ShortcutHelp.tsx | 74 +- .../markdown/HeadingWithPermalink.tsx | 4 +- .../memberships/PendingInvitationBanner.tsx | 33 +- .../features/metadata/AccountMetadata.tsx | 12 +- .../features/metadata/NotFoundMetadata.tsx | 8 +- .../features/metadata/ProductMetadata.tsx | 6 +- .../features/metadata/STACSection.tsx | 7 +- .../features/onboarding/OnboardingForm.tsx | 20 +- .../OrganizationCreationForm.tsx | 34 +- .../features/products/ChecksumVerifier.tsx | 14 +- .../features/products/DeleteProductModal.tsx | 38 +- .../features/products/Pagination.tsx | 10 +- .../features/products/ProductCreationForm.tsx | 120 +- .../products/ProductDataUnavailable.tsx | 9 +- .../features/products/ProductListItem.tsx | 24 +- .../features/products/ProductMetaCard.tsx | 4 +- .../features/products/ProductMetaContent.tsx | 16 +- .../features/products/ProductSearchResult.tsx | 8 +- .../features/products/ProductsFilters.tsx | 10 +- .../features/products/ProductsList.tsx | 6 +- .../features/products/ProductsSkeleton.tsx | 4 +- .../products/ProxyCredentialsGate.tsx | 10 +- .../products/object-browser/DirectoryList.tsx | 6 +- .../products/object-browser/DirectoryRow.tsx | 47 +- .../object-browser/ObjectPreviewExternal.tsx | 21 +- .../products/object-browser/ObjectSummary.tsx | 18 +- .../products/object-browser/PreviewIframe.tsx | 5 +- .../object-browser/ProductContentsCard.tsx | 4 +- .../object-browser/RefreshListingButton.tsx | 6 +- .../features/profiles/EditProfileForm.tsx | 62 +- .../profiles/EmailVerificationStatus.tsx | 14 +- .../features/profiles/IndividualProfile.tsx | 18 +- .../features/profiles/OrganizationMembers.tsx | 10 +- .../features/profiles/OrganizationProfile.tsx | 16 +- .../features/profiles/ProfileImageUpload.tsx | 55 +- .../features/profiles/WelcomeCallout.tsx | 20 +- .../service-accounts/AddGithubTrustDialog.tsx | 15 +- .../service-accounts/AddSignInMenu.tsx | 12 +- .../service-accounts/ApiKeyExpiryField.tsx | 18 +- .../features/service-accounts/ApiKeyList.tsx | 91 +- .../service-accounts/ExampleUsage.tsx | 14 +- .../service-accounts/GithubWorkflowFields.tsx | 132 +- .../service-accounts/GrantProductDialog.tsx | 22 +- .../service-accounts/IssueApiKeyDialog.tsx | 20 +- .../service-accounts/IssuedApiKey.tsx | 8 +- .../service-accounts/IssuedApiKeyDialog.tsx | 6 +- .../service-accounts/ProductAccessList.tsx | 20 +- .../service-accounts/ServiceAccountDetail.tsx | 76 +- .../service-accounts/ServiceAccountForm.tsx | 45 +- .../service-accounts/ServiceAccountList.tsx | 16 +- .../features/settings/MembershipsTable.tsx | 34 +- .../features/uploader/ActivitySubmenu.tsx | 8 +- .../features/uploader/DeleteProgress.tsx | 38 +- src/components/features/uploader/Dropzone.tsx | 4 +- .../uploader/FetchCredentialsButton.tsx | 12 +- .../uploader/GlobalUploadNotification.tsx | 22 +- .../features/uploader/UploadProvider.tsx | 12 +- .../uploader/ViewCredentialsDialog.tsx | 54 +- src/components/layout/AccountDropdown.tsx | 31 +- src/components/layout/AuthButtons.tsx | 7 +- src/components/layout/MobileMenu.tsx | 44 +- src/i18n/messages/en.json | 1321 +++++++++++++++++ src/i18n/messages/ja.json | 1321 +++++++++++++++++ src/lib/actions/account.ts | 46 +- src/lib/actions/data-connections.ts | 56 +- src/lib/actions/memberships.ts | 55 +- src/lib/actions/product-mirrors.ts | 78 +- src/lib/actions/products.ts | 61 +- src/lib/actions/service-account-keys.ts | 24 +- src/lib/actions/service-accounts.ts | 60 +- src/lib/format.ts | 42 - src/lib/index.ts | 2 +- 152 files changed, 4751 insertions(+), 1745 deletions(-) diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx index 4186fab12..0e9cf84ea 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/-/analytics/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { InfoCircledIcon } from "@radix-ui/react-icons"; import { Box, @@ -47,7 +48,8 @@ export async function generateMetadata({ if (!isAuthorized(session, product, Actions.PutRepository)) { notFound(); } - return { title: `${product.title || product_id} — Analytics` }; + const t = await getTranslations("ProductAnalyticsPage"); + return { title: t("metaTitle", { title: product.title || product_id }) }; } function parseWindow(value: string | string[] | undefined): UsageWindow { @@ -72,6 +74,7 @@ export default async function ProductAnalyticsPage({ } const windowDays = parseWindow((await searchParams).window); + const t = await getTranslations("ProductAnalyticsPage"); const [usage, breakdowns] = await Promise.all([ getUsage(account_id, product_id, undefined, windowDays), getProductBreakdowns(account_id, product_id, windowDays), @@ -93,19 +96,17 @@ export default async function ProductAnalyticsPage({ - Analytics is a preview feature. The metrics shown here and who can - access them may change in the near future. Let us know what you think - at{" "} - - hello@source.coop - - . + {t.rich("previewNotice", { + link: (chunks) => ( + {chunks} + ), + })} {USAGE_WINDOWS.map((days) => ( @@ -118,7 +119,7 @@ export default async function ProductAnalyticsPage({ - {days}d + {t("windowDays", { days })} ))} @@ -136,7 +137,7 @@ export default async function ProductAnalyticsPage({ /> ) : ( - Analytics are unavailable right now. Try again in a few minutes. + {t("unavailable")} )} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx index 5207fa3d1..410db5946 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/@preview/page.tsx @@ -12,6 +12,7 @@ import { fileSourceUrl } from "@/lib/urls"; import { ExternalLinkIcon } from "@radix-ui/react-icons"; import { getExtension, isStoreExtension } from "@/lib/files"; import { getIframeSrc } from "@/components/features/products/object-browser/ObjectPreviewExternal"; +import { getTranslations } from "next-intl/server"; interface PageProps { params: Promise<{ @@ -21,11 +22,12 @@ interface PageProps { }>; } -function previewCard(viewerUrl: string | null, children: ReactNode) { +async function previewCard(viewerUrl: string | null, children: ReactNode) { + const t = await getTranslations("ObjectPreviewSlot"); return ( - Open in new tab + {t("openInNewTab")} @@ -110,7 +112,9 @@ export default async function ObjectPreviewSlot({ params }: PageProps) { viewerUrl, , ) : null; diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx index c50bddc6e..250f21629 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/layout.tsx @@ -29,6 +29,7 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { getPendingInvitation } from "@/lib/actions/memberships"; import { ProductSchemaMetadata } from "@/components/features/products/ProductSchemaMetadata"; import { getAuthorizedProduct } from "./data"; +import { getTranslations } from "next-intl/server"; interface ProductLayoutProps { children: React.ReactNode; @@ -71,6 +72,7 @@ export default async function ProductLayout({ // Check for pending invitation const pendingInvitation = await getPendingInvitation(account_id, product_id); + const t = await getTranslations("ProductLayout"); return ( <> @@ -84,10 +86,7 @@ export default async function ProductLayout({ - - This product is deactivated. It is hidden from everyone except its - owners and administrators. - + {t("deactivatedNotice")} )} diff --git a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx index 48a2fe9f9..c079be302 100644 --- a/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx +++ b/src/app/(app)/[account_id]/[product_id]/(product)/[[...path]]/page.tsx @@ -12,6 +12,7 @@ import { ProductDataUnavailable } from "@/components/features/products/ProductDa import { DirectoryList } from "@/components/features/products/object-browser/DirectoryList"; import { ObjectSummary } from "@/components/features/products/object-browser/ObjectSummary"; import { generateProductMetadata } from "@/components/features/metadata/ProductMetadata"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, @@ -32,14 +33,14 @@ interface PageProps { }>; } -// Shown when a read through the data proxy fails for a non-authz reason (the -// backend hung / 5xx'd / returned an unparseable body). Recoverable, so the -// ProductDataUnavailable "Try again" button refreshes. -const DATA_UNAVAILABLE_MESSAGE = - "This product's files couldn't be loaded just now — the storage backend didn't respond. Try again in a moment."; - export default async function ProductPathPage({ params }: PageProps) { let { account_id, product_id, path } = await params; + // Shown when a read through the data proxy fails for a non-authz reason (the + // backend hung / 5xx'd / returned an unparseable body). Recoverable, so the + // ProductDataUnavailable "Try again" button refreshes. + const DATA_UNAVAILABLE_MESSAGE = (await getTranslations("ProductPage"))( + "dataUnavailable" + ); path = path?.map((p) => decodeURIComponent(p)) || []; const objectPath = path.join("/"); diff --git a/src/app/(app)/[account_id]/[product_id]/error.tsx b/src/app/(app)/[account_id]/[product_id]/error.tsx index 6d8e3e15e..3ad9536b7 100644 --- a/src/app/(app)/[account_id]/[product_id]/error.tsx +++ b/src/app/(app)/[account_id]/[product_id]/error.tsx @@ -10,6 +10,7 @@ import { Heading, Text, } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; /** * Error boundary for the product route subtree (the product layout, the path @@ -25,6 +26,7 @@ export default function ProductError({ reset: () => void; }) { const router = useRouter(); + const t = useTranslations("ProductErrorPage"); useEffect(() => { // Surface the error in the browser console for debugging; the full @@ -44,22 +46,19 @@ export default function ProductError({ return ( - Something went wrong loading this product + {t("heading")} - - We couldn't load this product's contents right now. This is - usually temporary — please try again in a moment. - + {t("body")} {error.digest && ( - Reference: {error.digest} + {t("reference", { digest: error.digest })} )} diff --git a/src/app/(app)/[account_id]/[product_id]/loading.tsx b/src/app/(app)/[account_id]/[product_id]/loading.tsx index fc56bd4b5..1b7c17c2c 100644 --- a/src/app/(app)/[account_id]/[product_id]/loading.tsx +++ b/src/app/(app)/[account_id]/[product_id]/loading.tsx @@ -3,8 +3,10 @@ import { SectionHeader, Skeleton } from "@/components/core"; import { UsageCardSkeleton } from "@/components/features/analytics"; import { isAnalyticsConfigured } from "@/lib/clients/analytics"; import DirectoryListLoading from "@/components/features/products/object-browser/DirectoryListLoading"; +import { useTranslations } from "next-intl"; export default function ProductDetailsLoading() { + const t = useTranslations("ProductLoading"); return ( {/* Left column: summary + contents */} @@ -35,7 +37,7 @@ export default function ProductDetailsLoading() { {/* Breadcrumb Skeleton */} - + - + {/* Visibility Badge Skeleton */} diff --git a/src/app/(app)/[account_id]/[product_id]/not-found.tsx b/src/app/(app)/[account_id]/[product_id]/not-found.tsx index 186ae2eed..aa4106efd 100644 --- a/src/app/(app)/[account_id]/[product_id]/not-found.tsx +++ b/src/app/(app)/[account_id]/[product_id]/not-found.tsx @@ -1,21 +1,28 @@ import { NotFoundPage } from "@/components/core"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; -export const metadata: Metadata = { - title: "Product Not Found | Source Cooperative", - description: "The requested product could not be found.", - openGraph: { - title: "Product Not Found | Source Cooperative", - description: "The requested product could not be found.", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Product Not Found | Source Cooperative", - description: "The requested product could not be found.", - }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("ProductNotFoundPage"); + const title = t("metaTitle"); + const description = t("metaDescription"); + return { + title, + description, + openGraph: { + title, + description, + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + }; +} export default async function NotFound() { - return ; + const t = await getTranslations("ProductNotFoundPage"); + return ; } diff --git a/src/app/(app)/[account_id]/not-found.tsx b/src/app/(app)/[account_id]/not-found.tsx index c2cc04742..08aac82a4 100644 --- a/src/app/(app)/[account_id]/not-found.tsx +++ b/src/app/(app)/[account_id]/not-found.tsx @@ -1,10 +1,7 @@ +import { getTranslations } from "next-intl/server"; import { NotFoundPage } from "@/components/core"; export default async function AccountNotFound() { - return ( - - ); + const t = await getTranslations("AccountNotFoundPage"); + return ; } diff --git a/src/app/(app)/[account_id]/organization/new/page.tsx b/src/app/(app)/[account_id]/organization/new/page.tsx index 4355b827c..f9af5b76a 100644 --- a/src/app/(app)/[account_id]/organization/new/page.tsx +++ b/src/app/(app)/[account_id]/organization/new/page.tsx @@ -2,6 +2,7 @@ import { OrganizationCreationForm } from "@/components/features/organizations/Or import { FormTitle } from "@/components/core"; import { getPageSession } from "@/lib/api/utils"; import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { Container, Heading, Text } from "@radix-ui/themes"; import { newOrganizationUrl } from "@/lib/urls"; @@ -14,16 +15,17 @@ interface PageProps { export default async function NewOrganizationPage({ params }: PageProps) { const { account_id } = await params; const session = await getPageSession(); + const t = await getTranslations("NewOrganizationPage"); if (!session?.account) { return ( - Access Denied + {t("accessDenied")} - You must be logged in to create an organization. + {t("loginRequired")} ); @@ -36,8 +38,8 @@ export default async function NewOrganizationPage({ params }: PageProps) { return ( <> diff --git a/src/app/(app)/admin/analytics/page.tsx b/src/app/(app)/admin/analytics/page.tsx index 24d1241f6..03a061a31 100644 --- a/src/app/(app)/admin/analytics/page.tsx +++ b/src/app/(app)/admin/analytics/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; +import { getFormatter, getTranslations } from "next-intl/server"; import { getPageSession } from "@/lib"; import { isAdmin } from "@/lib/api/authz"; import { @@ -33,16 +34,32 @@ import { AdminBreakdownChart, seriesColor, } from "@/components/features/analytics"; -// Components come from the client module; HELP/mono must come from the -// plain style module — client-module exports can't be called on the server. +// Components come from the client module; mono must come from the plain +// style module — client-module exports can't be called on the server. import { MonoLabel } from "@/components/features/analytics/panels"; -import { HELP, mono } from "@/components/features/analytics/style"; +import { mono } from "@/components/features/analytics/style"; import { AdminFiltersForm } from "@/components/features/analytics/AdminFiltersForm"; import { GroupByChips } from "@/components/features/analytics/GroupByChips"; import { adminAnalyticsUrl, formatBytes } from "@/lib"; import { accountUrl } from "@/lib/urls"; -export const metadata: Metadata = { title: "Admin — Analytics" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("AdminAnalyticsPage"); + return { title: t("metaTitle") }; +} + +type Translator = Awaited< + ReturnType> +>; + +/** Message keys for the BUCKET_INTERVALS ladder, by minutes. */ +const INTERVAL_KEY = { + 1: "minute", + 60: "hourly", + 360: "sixHour", + 1440: "daily", + 10080: "weekly", +} as const; interface PageState { /** @@ -64,8 +81,6 @@ interface PageState { const first = (v: string | string[] | undefined) => Array.isArray(v) ? v[0] : v; -const numberFormat = new Intl.NumberFormat("en-US"); - const DAY_MS = 86_400_000; const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10); const todayUtc = () => new Date().setUTCHours(0, 0, 0, 0); @@ -80,26 +95,21 @@ const paramMs = (value: string): number => Date.parse(value.length === 10 ? `${value}T00:00:00Z` : `${value}:00Z`); /** "15-minute" / "6-hour" / "3-day" for any ladder value. */ -const bucketName = (minutes: number): string => +const bucketName = (t: Translator, minutes: number): string => minutes < 60 - ? `${minutes}-minute` + ? t("bucketMinutes", { n: minutes }) : minutes < 1440 - ? `${minutes / 60}-hour` - : `${minutes / 1440}-day`; + ? t("bucketHours", { n: minutes / 60 }) + : t("bucketDays", { n: minutes / 1440 }); /** "6 hours" / "16 days" — the longest range an interval can draw. */ -const spanName = (minutes: number): string => +const spanName = (t: Translator, minutes: number): string => minutes < 1440 - ? `${Math.floor(minutes / 60)} hours` - : `${Math.floor(minutes / 1440)} days`; + ? t("spanHours", { n: Math.floor(minutes / 60) }) + : t("spanDays", { n: Math.floor(minutes / 1440) }); // Whole weeks (plus "Today"), to avoid aliasing day-of-week patterns. -const PRESETS = [ - { label: "Today", days: 1 }, - { label: "7d", days: 7 }, - { label: "28d", days: 28 }, - { label: "91d", days: 91 }, -]; +const PRESETS = [1, 7, 28, 91]; function parseState(params: Record): PageState { const groupByParam = first(params.groupBy); @@ -194,18 +204,30 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { } const state = parseState(await searchParams); + const t = await getTranslations("AdminAnalyticsPage"); + const tStat = await getTranslations("AnalyticsPanels"); + const tHelp = await getTranslations("AnalyticsHelp"); + const format = await getFormatter(); + const dimensionOptions = ( + Object.keys(ADMIN_DIMENSIONS) as AdminDimension[] + ).map((dim) => ({ key: dim, label: t(`dimensions.${dim}`) })); if (!isAnalyticsConfigured()) { return ( - Analytics + {t("heading")} - Analytics is not configured. Set CF_ANALYTICS_ACCOUNT_ID, - CF_ANALYTICS_API_TOKEN, and CF_ANALYTICS_DATASET. + {t("notConfigured", { + vars: format.list([ + "CF_ANALYTICS_ACCOUNT_ID", + "CF_ANALYTICS_API_TOKEN", + "CF_ANALYTICS_DATASET", + ]), + })} @@ -238,7 +260,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { const retentionEdge = today - RETENTION_DAYS * DAY_MS; const rangeDays = Math.round((lastDayMs - fromDayMs) / DAY_MS) + 1; const rangeMinutes = (endMs - fromMs) / 60_000; - const rangeLabel = `${rangeDays} day${rangeDays === 1 ? "" : "s"}`; + const rangeLabel = t("rangeDays", { days: rangeDays }); // Shift the whole range by N days, clamped so its length is preserved at // the edges (today forward, ~retention backward). Shifting a drilled // sub-day range deliberately widens it back to whole days. @@ -264,7 +286,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { return ( - Analytics + {t("heading")} {/* Two zones: what data (dates + entity filters) | how it's drawn (group by + interval), split by the stats-row hairline. */} @@ -281,47 +303,49 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { > - Date range (UTC) + {t("dateRange")} - {PRESETS.map((preset) => { - const from = isoDay(today - (preset.days - 1) * DAY_MS); + {PRESETS.map((days) => { + const from = isoDay(today - (days - 1) * DAY_MS); const to = isoDay(today); const active = range.from === from && range.to === to; return ( ); })} @@ -332,12 +356,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { // so prop changes alone never reach them. key={`${range.from}|${range.to}|${JSON.stringify(state.filters)}`} action={adminAnalyticsUrl()} - dimensions={( - Object.keys(ADMIN_DIMENSIONS) as AdminDimension[] - ).map((dim) => ({ - key: dim, - label: ADMIN_DIMENSIONS[dim].label, - }))} + dimensions={dimensionOptions} defaults={{ // datetime-local values over the resolved [from, end) — // midnight-aligned submissions collapse back to day grain @@ -369,24 +388,19 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { > - Group by + {t("groupBy")} ({ - key: dim, - label: ADMIN_DIMENSIONS[dim].label, - }))} + dimensions={dimensionOptions} selected={state.groupBy} /> - Interval + {t("interval")} {BUCKET_INTERVALS.map((bucket) => { + const label = t(`intervals.${INTERVAL_KEY[bucket.minutes]}`); // An interval that would draw more bars than the chart can // hold is disabled rather than silently coarsened. const fits = @@ -407,10 +422,14 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { return ( ); @@ -423,7 +442,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { variant={state.bucketMinutes === bucket.minutes ? "solid" : "soft"} > - {bucket.label} + {label} ); @@ -448,8 +467,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { - No traffic recorded for this selection between {range.from} and{" "} - {range.to}. + {t("noTraffic", { from: range.from, to: range.to })} ) : ( @@ -458,9 +476,10 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { {state.bucketMinutes !== undefined && breakdown.bucketMinutes !== state.bucketMinutes && ( - Showing {bucketName(breakdown.bucketMinutes)} buckets — the - requested interval would draw more than {MAX_CHART_BUCKETS}{" "} - bars over this range. + {t("coarsened", { + bucket: bucketName(t, breakdown.bucketMinutes), + max: MAX_CHART_BUCKETS, + })} )} {state.groupBy.length ? state.groupBy - .map((d) => ADMIN_DIMENSIONS[d].label) + .map((d) => t(`dimensions.${d}`)) .join(" · ") - : "Scope"} + : t("scope")} - Data served + + {tStat("dataServed")} + - Requests + + {tStat("requests")} + - Share + {t("share")} @@ -533,6 +556,8 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { {href ? ( {group.key} + ) : group.key === OTHER_KEY ? ( + t("other") ) : ( group.key )} @@ -546,7 +571,7 @@ export default async function AdminAnalyticsPage({ searchParams }: PageProps) { - {numberFormat.format(Math.round(group.requests))} + {format.number(Math.round(group.requests))} diff --git a/src/app/(app)/admin/data-connections/[data_connection_id]/page.tsx b/src/app/(app)/admin/data-connections/[data_connection_id]/page.tsx index 7cab1c94b..970fff6b8 100644 --- a/src/app/(app)/admin/data-connections/[data_connection_id]/page.tsx +++ b/src/app/(app)/admin/data-connections/[data_connection_id]/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Box, Button, Flex, Text } from "@radix-ui/themes"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { dataConnectionsTable } from "@/lib/clients"; import { DataConnectionForm, @@ -15,9 +16,10 @@ import { toEditableDataConnection } from "@/components/features/data-connections import { DangerZone } from "@/components/core"; import { adminDataConnectionsUrl } from "@/lib/urls"; -export const metadata: Metadata = { - title: "Admin — Edit data connection", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("DataConnectionEditPage"); + return { title: t("adminMetaTitle") }; +} interface EditDataConnectionPageProps { params: Promise<{ data_connection_id: string }>; @@ -34,13 +36,14 @@ export default async function EditDataConnectionPage({ if (!dataConnection) { notFound(); } + const t = await getTranslations("DataConnectionEditPage"); return ( @@ -52,7 +55,7 @@ export default async function EditDataConnectionPage({ - Loading product usage… + {t("loadingUsage")} } > @@ -61,8 +64,8 @@ export default async function EditDataConnectionPage({ {/* Below the usage list, which is what answers "can I delete this?" */} { + const t = await getTranslations("AdminCreateDataConnectionPage"); + return { title: t("metaTitle") }; +} export default function CreateDataConnectionPage() { + const t = useTranslations("AdminCreateDataConnectionPage"); return ( - Create Data Connection + {t("title")} ); diff --git a/src/app/(app)/admin/data-connections/page.tsx b/src/app/(app)/admin/data-connections/page.tsx index 3b20ef0d4..708606fad 100644 --- a/src/app/(app)/admin/data-connections/page.tsx +++ b/src/app/(app)/admin/data-connections/page.tsx @@ -2,15 +2,17 @@ import { Metadata } from "next"; import { accountsTable, dataConnectionsTable } from "@/lib/clients"; import { Flex, Button, Heading } from "@radix-ui/themes"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { DataConnectionsList } from "@/components/features/data-connections"; import { adminDataConnectionCreateUrl, adminDataConnectionEditUrl, } from "@/lib/urls"; -export const metadata: Metadata = { - title: "Admin — Data connections", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("AdminDataConnectionsPage"); + return { title: t("metaTitle") }; +} export default async function DataConnectionsPage() { const connections = (await dataConnectionsTable.listAll()).sort( @@ -28,13 +30,14 @@ export default async function DataConnectionsPage() { acct, ]) ); + const t = await getTranslations("AdminDataConnectionsPage"); return ( - Data Connections + {t("title")} diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx index aa10e3fe1..bb73b3bd9 100644 --- a/src/app/(app)/admin/layout.tsx +++ b/src/app/(app)/admin/layout.tsx @@ -1,5 +1,6 @@ import { ReactNode } from "react"; import { Heading } from "@radix-ui/themes"; +import { getTranslations } from "next-intl/server"; import { getPageSession } from "@/lib/api/utils"; import { isAdmin } from "@/lib/api/authz"; import { NotAuthorizedPage } from "@/components/core"; @@ -20,9 +21,11 @@ export default async function AdminLayout({ return ; } + const t = await getTranslations("AdminLayout"); + const tTools = await getTranslations("AdminTools"); const menuItems = ADMIN_TOOLS.map((tool) => ({ id: tool.href, - label: tool.name, + label: tTools(`${tool.key}.name`), href: tool.href, icon: , condition: true, @@ -31,7 +34,7 @@ export default async function AdminLayout({ return ( <> - Admin + {t("title")} {children} diff --git a/src/app/(app)/admin/user-lookup/page.tsx b/src/app/(app)/admin/user-lookup/page.tsx index 2cc7fdc33..0053e8159 100644 --- a/src/app/(app)/admin/user-lookup/page.tsx +++ b/src/app/(app)/admin/user-lookup/page.tsx @@ -1,13 +1,15 @@ import { Metadata } from "next"; import { Box } from "@radix-ui/themes"; +import { getTranslations } from "next-intl/server"; import { FormTitle } from "@/components/core"; import { AdminUserLookup } from "@/components/features/admin/AdminUserLookup"; import { searchUsers } from "@/lib/api/user-lookup"; import { getPageSession } from "@/lib/api/utils"; -export const metadata: Metadata = { - title: "Admin — User lookup", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("AdminUserLookupPage"); + return { title: t("metaTitle") }; +} // Access is gated by the /admin layout. export default async function AdminUserLookupPage({ @@ -19,12 +21,13 @@ export default async function AdminUserLookupPage({ const search = q ? await searchUsers(q, (await getPageSession())?.account?.account_id) : undefined; + const t = await getTranslations("AdminUserLookupPage"); return ( diff --git a/src/app/(app)/edit/account/[account_id]/data-connections/[data_connection_id]/page.tsx b/src/app/(app)/edit/account/[account_id]/data-connections/[data_connection_id]/page.tsx index c6aaae5d5..84e431c0b 100644 --- a/src/app/(app)/edit/account/[account_id]/data-connections/[data_connection_id]/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/data-connections/[data_connection_id]/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { Box, Button, Text } from "@radix-ui/themes"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; import { accountsTable, dataConnectionsTable } from "@/lib/clients"; import { getPageSession } from "@/lib/api/utils"; import { canManageAccountDataConnections } from "@/lib/api/authz"; @@ -17,9 +18,10 @@ import { toEditableDataConnection } from "@/components/features/data-connections import { DangerZone } from "@/components/core"; import { accountDataConnectionsUrl } from "@/lib/urls"; -export const metadata: Metadata = { - title: "Edit data connection", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("DataConnectionEditPage"); + return { title: t("metaTitle") }; +} interface PageProps { params: Promise<{ account_id: string; data_connection_id: string }>; @@ -42,12 +44,13 @@ export default async function AccountEditDataConnectionPage({ if (!dataConnection || dataConnection.owner !== account_id) { notFound(); } + const t = await getTranslations("DataConnectionEditPage"); return ( - Loading product usage… + {t("loadingUsage")} } > @@ -70,8 +73,8 @@ export default async function AccountEditDataConnectionPage({ {/* Below the usage list, which is what answers "can I delete this?" */} { + const t = await getTranslations("AccountCreateDataConnectionPage"); + return { title: t("metaTitle") }; +} interface PageProps { params: Promise<{ account_id: string }>; @@ -24,12 +26,13 @@ export default async function AccountCreateDataConnectionPage({ if (!account || !canManageAccountDataConnections(session, account)) { notFound(); } + const t = await getTranslations("AccountCreateDataConnectionPage"); return ( diff --git a/src/app/(app)/edit/account/[account_id]/data-connections/page.tsx b/src/app/(app)/edit/account/[account_id]/data-connections/page.tsx index e2109d9d1..193aebaf5 100644 --- a/src/app/(app)/edit/account/[account_id]/data-connections/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/data-connections/page.tsx @@ -2,6 +2,7 @@ import { Metadata } from "next"; import { notFound } from "next/navigation"; import { Box, Flex, Button } from "@radix-ui/themes"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { accountsTable, dataConnectionsTable } from "@/lib/clients"; import { getPageSession } from "@/lib/api/utils"; import { canManageAccountDataConnections } from "@/lib/api/authz"; @@ -12,9 +13,10 @@ import { accountDataConnectionEditUrl, } from "@/lib/urls"; -export const metadata: Metadata = { - title: "Data connections", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("AccountDataConnectionsPage"); + return { title: t("metaTitle") }; +} interface PageProps { params: Promise<{ account_id: string }>; @@ -37,19 +39,20 @@ export default async function AccountDataConnectionsPage({ params }: PageProps) a.details.provider.localeCompare(b.details.provider) || a.name.localeCompare(b.name) ); + const t = await getTranslations("AccountDataConnectionsPage"); return ( diff --git a/src/app/(app)/edit/account/[account_id]/layout.tsx b/src/app/(app)/edit/account/[account_id]/layout.tsx index b40d9d513..4d056369a 100644 --- a/src/app/(app)/edit/account/[account_id]/layout.tsx +++ b/src/app/(app)/edit/account/[account_id]/layout.tsx @@ -35,6 +35,7 @@ import { } from "@/lib/urls"; import { LinkAway } from "@/components/core/LinkAway"; import { getManageableAccounts } from "@/lib/clients/lookups"; +import { getTranslations } from "next-intl/server"; interface AccountLayoutProps { children: ReactNode; @@ -46,6 +47,7 @@ export default async function AccountLayout({ params, }: AccountLayoutProps) { const { account_id } = await params; + const t = await getTranslations("EditAccountLayout"); const userSession = await getPageSession(); @@ -96,21 +98,21 @@ export default async function AccountLayout({ const menuItems = [ { id: "profile", - label: "Details", + label: t("details"), href: editAccountProfileUrl(account_id), icon: , condition: canReadAccount, }, { id: "profile-picture", - label: "Profile Picture", + label: t("profilePicture"), href: editAccountProfilePictureUrl(account_id), icon: , condition: canEditAccount, }, { id: "data-connections", - label: "Data Connections", + label: t("dataConnections"), href: accountDataConnectionsUrl(account_id), icon: , condition: canManageDataConnections, @@ -119,7 +121,7 @@ export default async function AccountLayout({ // view requires GetAccountFlags, edit is admin-only (enforced in the form). { id: "permissions", - label: "Permissions", + label: t("permissions"), href: editAccountPermissionsUrl(account_id), icon: , condition: isAuthorized( @@ -132,7 +134,7 @@ export default async function AccountLayout({ // granted the CREATE_SERVICE_ACCOUNTS flag. { id: "service-accounts", - label: "Service Accounts", + label: t("serviceAccounts"), href: editAccountServiceAccountsUrl(account_id), icon: , condition: canManageAccountServiceAccounts(userSession, accountToEdit), @@ -141,7 +143,7 @@ export default async function AccountLayout({ ? [ { id: "memberships", - label: "Memberships", + label: t("memberships"), href: editAccountMembershipsUrl(account_id), icon: , condition: canReadMembership, @@ -150,14 +152,13 @@ export default async function AccountLayout({ : [ { id: "authentication", - label: "Authentication", + label: t("authentication"), href: orySettingsUrl(), icon: , condition: canReadAccount, external: true, disabled: !isOwnAccount, - disabledTooltip: - "Admins can't edit another user's authentication details.", + disabledTooltip: t("authenticationDisabledTooltip"), }, ]), ]; @@ -172,7 +173,7 @@ export default async function AccountLayout({ /> - View Profile + {t("viewProfile")} diff --git a/src/app/(app)/edit/account/[account_id]/memberships/page.tsx b/src/app/(app)/edit/account/[account_id]/memberships/page.tsx index 14356976d..73eed946e 100644 --- a/src/app/(app)/edit/account/[account_id]/memberships/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/memberships/page.tsx @@ -13,13 +13,15 @@ import { notFound, redirect } from "next/navigation"; import { getPageSession } from "@/lib"; import { isAuthorized } from "@/lib/api/authz"; import { editAccountProfileUrl } from "@/lib/urls"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, }: PageProps): Promise { const { account_id } = await params; const account = await accountsTable.fetchById(account_id); - return { title: `Edit ${account!.name} memberships` }; + const t = await getTranslations("EditAccountMembershipsPage"); + return { title: t("metaTitle", { name: account!.name }) }; } interface PageProps { @@ -72,13 +74,15 @@ export default async function MembershipsPage({ params }: PageProps) { Actions.InviteMembership ); + const t = await getTranslations("EditAccountMembershipsPage"); + return ( {canInviteMembership && } @@ -88,8 +92,8 @@ export default async function MembershipsPage({ params }: PageProps) { memberships={activeMemberships} memberAccountsMap={memberAccountsMap} userSession={userSession} - emptyStateMessage="No members yet" - emptyStateDescription="Invite people to join your organization" + emptyStateMessage={t("emptyTitle")} + emptyStateDescription={t("emptyDescription")} /> ); diff --git a/src/app/(app)/edit/account/[account_id]/permissions/page.tsx b/src/app/(app)/edit/account/[account_id]/permissions/page.tsx index f646d85d5..9c3b45046 100644 --- a/src/app/(app)/edit/account/[account_id]/permissions/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/permissions/page.tsx @@ -8,13 +8,15 @@ import { NotAuthorizedPage } from "@/components/core"; import { notFound } from "next/navigation"; import { isAuthorized } from "@/lib/api/authz"; import { Actions } from "@/types"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, }: PageProps): Promise { const { account_id } = await params; const account = await accountsTable.fetchById(account_id); - return { title: `Edit ${account!.name} permissions` }; + const t = await getTranslations("EditAccountPermissionsPage"); + return { title: t("metaTitle", { name: account!.name }) }; } interface PageProps { params: Promise<{ account_id: string }>; @@ -33,11 +35,12 @@ export default async function PermissionsPage({ params }: PageProps) { if (!isAuthorized(session, account, Actions.GetAccountFlags)) { return ; } + const t = await getTranslations("EditAccountPermissionsPage"); return ( diff --git a/src/app/(app)/edit/account/[account_id]/profile-picture/page.tsx b/src/app/(app)/edit/account/[account_id]/profile-picture/page.tsx index c5bfba82f..85c13359d 100644 --- a/src/app/(app)/edit/account/[account_id]/profile-picture/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/profile-picture/page.tsx @@ -4,13 +4,15 @@ import { Box } from "@radix-ui/themes"; import { FormTitle } from "@/components/core/FormTitle"; import { ProfileImageUpload } from "@/components/features/profiles/ProfileImageUpload"; import { accountsTable } from "@/lib/clients/database"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, }: PageProps): Promise { const { account_id } = await params; const account = await accountsTable.fetchById(account_id); - return { title: `${account!.name} settings` }; + const t = await getTranslations("EditAccountProfilePicturePage"); + return { title: t("metaTitle", { name: account!.name }) }; } interface PageProps { @@ -25,13 +27,17 @@ export default async function ProfilePicturePage({ params }: PageProps) { notFound(); } + const t = await getTranslations("EditAccountProfilePicturePage"); + return ( diff --git a/src/app/(app)/edit/account/[account_id]/profile/page.tsx b/src/app/(app)/edit/account/[account_id]/profile/page.tsx index 4ebd97dc1..96c448aea 100644 --- a/src/app/(app)/edit/account/[account_id]/profile/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/profile/page.tsx @@ -4,13 +4,15 @@ import { Box } from "@radix-ui/themes"; import { EditProfileForm } from "@/components/features/profiles/EditProfileForm"; import { FormTitle } from "@/components/core/FormTitle"; import { accountsTable } from "@/lib/clients/database"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, }: PageProps): Promise { const { account_id } = await params; const account = await accountsTable.fetchById(account_id); - return { title: `Edit ${account!.name} profile` }; + const t = await getTranslations("EditAccountProfilePage"); + return { title: t("metaTitle", { name: account!.name }) }; } interface PageProps { @@ -24,14 +26,15 @@ export default async function ProfilePage({ params }: PageProps) { if (!account) { notFound(); } + const t = await getTranslations("EditAccountProfilePage"); return ( diff --git a/src/app/(app)/edit/account/[account_id]/service-accounts/[service_account_id]/page.tsx b/src/app/(app)/edit/account/[account_id]/service-accounts/[service_account_id]/page.tsx index 312003663..b98e00b86 100644 --- a/src/app/(app)/edit/account/[account_id]/service-accounts/[service_account_id]/page.tsx +++ b/src/app/(app)/edit/account/[account_id]/service-accounts/[service_account_id]/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { Box, Button } from "@radix-ui/themes"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; +import { getTranslations } from "next-intl/server"; import { ServiceAccountDetail } from "@/components/features/service-accounts"; import { accountTrustsTable, @@ -16,7 +17,10 @@ import { managedServiceAccount } from "@/lib/accounts/service-accounts"; import { editAccountServiceAccountsUrl } from "@/lib/urls"; import { MembershipState, publicKey } from "@/types"; -export const metadata: Metadata = { title: "Service account" }; +export async function generateMetadata(): Promise { + const t = await getTranslations("ServiceAccountPage"); + return { title: t("metaTitle") }; +} interface PageProps { params: Promise<{ account_id: string; service_account_id: string }>; @@ -34,12 +38,13 @@ export default async function ServiceAccountPage({ params }: PageProps) { productsTable.listByAccountAll(account_id), serviceAccountKeysTable.listByAccount(account.account_id), ]); + const t = await getTranslations("ServiceAccountPage"); return ( { + const t = await getTranslations("CreateServiceAccountPage"); + return { title: t("metaTitle") }; +} interface PageProps { params: Promise<{ account_id: string }>; @@ -24,12 +28,13 @@ export default async function CreateServiceAccountPage({ params }: PageProps) { const products = (await productsTable.listByAccountAll(account_id)).map( ({ product_id, title }) => ({ product_id, title }) ); + const t = await getTranslations("CreateServiceAccountPage"); return ( { + const t = await getTranslations("ServiceAccountsPage"); + return { title: t("metaTitle") }; +} interface PageProps { params: Promise<{ account_id: string }>; @@ -41,17 +45,18 @@ export default async function ServiceAccountsPage({ params }: PageProps) { keys: (await serviceAccountKeysTable.listByAccount(account.account_id)).map(publicKey), })) ); + const t = await getTranslations("ServiceAccountsPage"); return ( diff --git a/src/app/(app)/edit/product/[account_id]/[product_id]/details/page.tsx b/src/app/(app)/edit/product/[account_id]/[product_id]/details/page.tsx index 6de55e0a4..938e73aea 100644 --- a/src/app/(app)/edit/product/[account_id]/[product_id]/details/page.tsx +++ b/src/app/(app)/edit/product/[account_id]/[product_id]/details/page.tsx @@ -15,13 +15,15 @@ import { DataConnectionObjectSchema, } from "@/types"; import { DangerZone } from "@/components/core"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, }: PageProps): Promise { const { account_id, product_id } = await params; const product = await productsTable.fetchById(account_id, product_id); - return { title: `Edit ${product!.title} details` }; + const t = await getTranslations("EditProductDetailsPage"); + return { title: t("metaTitle", { title: product!.title }) }; } interface PageProps { @@ -64,6 +66,7 @@ export default async function DetailsPage({ params }: PageProps) { // (account-owned) connections, or for admins on any connection. The server // re-checks this; this only decides whether to offer the option in the UI. const canPreserveData = !!connection?.owner || isAdmin(session); + const t = await getTranslations("EditProductDetailsPage"); return ( <> @@ -76,8 +79,8 @@ export default async function DetailsPage({ params }: PageProps) { {canDelete && ( , condition: canEditProduct, }, { id: "memberships", - label: "Memberships", + label: t("memberships"), href: editProductMembershipsUrl(account_id, product_id), icon: , condition: canReadMembership, }, { id: "data-connections", - label: "Data Connections", + label: t("dataConnections"), href: editProductDataConnectionsUrl(account_id, product_id), icon: , condition: canEditProduct || isAdmin(session), @@ -106,7 +108,7 @@ export default async function ProductLayout({ - View Product + {t("viewProduct")} {children} diff --git a/src/app/(app)/edit/product/[account_id]/[product_id]/memberships/page.tsx b/src/app/(app)/edit/product/[account_id]/[product_id]/memberships/page.tsx index 344fca99e..57534008c 100644 --- a/src/app/(app)/edit/product/[account_id]/[product_id]/memberships/page.tsx +++ b/src/app/(app)/edit/product/[account_id]/[product_id]/memberships/page.tsx @@ -14,13 +14,15 @@ import { getPageSession } from "@/lib"; import { isAuthorized } from "@/lib/api/authz"; import { editAccountProfileUrl, editAccountMembershipsUrl } from "@/lib/urls"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; export async function generateMetadata({ params, }: PageProps): Promise { const { account_id, product_id } = await params; const product = await productsTable.fetchById(account_id, product_id); - return { title: `Edit ${product!.title} memberships` }; + const t = await getTranslations("EditProductMembershipsPage"); + return { title: t("metaTitle", { title: product!.title }) }; } interface PageProps { params: Promise<{ account_id: string; product_id: string }>; @@ -92,12 +94,14 @@ export default async function MembershipsPage({ params }: PageProps) { Actions.InviteMembership ); + const t = await getTranslations("EditProductMembershipsPage"); + return ( {canInviteMembership && ( @@ -106,36 +110,37 @@ export default async function MembershipsPage({ params }: PageProps) { - Product Members + {t("productMembers")} - The following users have been explicitly granted access to this product + {t("productMembersDescription")} - Organization Members + {t("organizationMembers")} - The following users have implicit access to this product via their{" "} - - organization membership - + {t.rich("organizationMembersDescription", { + link: (chunks) => ( + {chunks} + ), + })} diff --git a/src/app/(app)/edit/product/[account_id]/[product_id]/not-found.tsx b/src/app/(app)/edit/product/[account_id]/[product_id]/not-found.tsx index 8f5964402..ebd8a5b3e 100644 --- a/src/app/(app)/edit/product/[account_id]/[product_id]/not-found.tsx +++ b/src/app/(app)/edit/product/[account_id]/[product_id]/not-found.tsx @@ -1,20 +1,19 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; -export const metadata: Metadata = { - title: "Product Not Found | Source Cooperative", - description: "The requested product could not be found.", - openGraph: { - title: "Product Not Found | Source Cooperative", - description: "The requested product could not be found.", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Product Not Found | Source Cooperative", - description: "The requested product could not be found.", - }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("EditProductNotFound"); + const title = t("metaTitle"); + const description = t("metaDescription"); + return { + title, + description, + openGraph: { title, description, type: "website" }, + twitter: { card: "summary_large_image", title, description }, + }; +} -export default function NotFoundPage() { - return
    Not Found
    ; -} \ No newline at end of file +export default async function NotFoundPage() { + const t = await getTranslations("EditProductNotFound"); + return
    {t("notFound")}
    ; +} diff --git a/src/app/(app)/onboarding/page.tsx b/src/app/(app)/onboarding/page.tsx index 82e67b92b..4300bf3a4 100644 --- a/src/app/(app)/onboarding/page.tsx +++ b/src/app/(app)/onboarding/page.tsx @@ -4,11 +4,12 @@ import { OnboardingForm } from "@/components/features/onboarding"; import { getPageSession } from "@/lib/api/utils"; import { FormTitle } from "@/components/core"; import { homeUrl, accountUrl } from "@/lib/urls"; +import { getTranslations } from "next-intl/server"; -export const metadata: Metadata = { - title: "Complete Your Profile", - description: "Choose your username and set up your profile", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("OnboardingPage"); + return { title: t("title"), description: t("metaDescription") }; +} export default async function OnboardingPage({ searchParams, @@ -31,12 +32,11 @@ export default async function OnboardingPage({ ); } + const t = await getTranslations("OnboardingPage"); + return ( <> - + ); diff --git a/src/app/(app)/products/loading.tsx b/src/app/(app)/products/loading.tsx index 1b8a53c3f..c6c6063e1 100644 --- a/src/app/(app)/products/loading.tsx +++ b/src/app/(app)/products/loading.tsx @@ -1,10 +1,12 @@ import { PageHeader } from "@/components/layout"; import { ProductsSkeleton } from "@/components/features/products/ProductsSkeleton"; +import { useTranslations } from "next-intl"; export default function ProductsLoading() { + const t = useTranslations("ProductsPage"); return ( <> - + ); diff --git a/src/app/(app)/products/new/page.tsx b/src/app/(app)/products/new/page.tsx index 9382dfd5b..eb955accc 100644 --- a/src/app/(app)/products/new/page.tsx +++ b/src/app/(app)/products/new/page.tsx @@ -5,6 +5,7 @@ import { listUsableDataConnections } from "@/lib/data-connections"; import { Actions, DataConnectionObjectSchema, MembershipState } from "@/types"; import { Heading, Text } from "@radix-ui/themes"; import { FormTitle } from "@/components/core"; +import { getTranslations } from "next-intl/server"; export default async function NewProductPage({ searchParams, @@ -13,15 +14,16 @@ export default async function NewProductPage({ }) { const { owner } = await searchParams; const session = await getPageSession(); + const t = await getTranslations("NewProductPage"); if (!session?.account) { return ( <> - Access Denied + {t("accessDenied")} - You must be logged in to create a product. + {t("mustLogIn")} ); @@ -31,10 +33,12 @@ export default async function NewProductPage({ if (!isAuthorized(session, "*", Actions.CreateRepository)) { return ( - You do not have permission to create products. + {t("noPermission")}
    - If you believe this is an error, please contact{" "} - hello@source.coop. + {t.rich("contactUs", { + email: "hello@source.coop", + code: (chunks) => {chunks}, + })}
    ); } @@ -68,8 +72,8 @@ export default async function NewProductPage({ return ( <> ({ getPaginatedProducts: jest.fn(), })); -import { metadata } from "./page"; +import { generateMetadata } from "./page"; describe("Products Listing Page Metadata", () => { - it("has proper OpenGraph metadata", () => { + it("has proper OpenGraph metadata", async () => { + const metadata = await generateMetadata(); expect(metadata.title).toBe("Products | Source Cooperative"); expect(metadata.description).toBe( "Browse and discover public data products on Source.coop" @@ -19,12 +20,13 @@ describe("Products Listing Page Metadata", () => { expect(metadata.openGraph?.description).toBe( "Browse and discover public data products on Source.coop" ); - expect(metadata.openGraph?.type).toBe("website"); + expect(metadata.openGraph).toHaveProperty("type", "website"); }); - it("has proper Twitter Card metadata", () => { + it("has proper Twitter Card metadata", async () => { + const metadata = await generateMetadata(); expect(metadata.twitter).toBeDefined(); - expect(metadata.twitter?.card).toBe("summary_large_image"); + expect(metadata.twitter).toHaveProperty("card", "summary_large_image"); expect(metadata.twitter?.title).toBe("Products | Source Cooperative"); expect(metadata.twitter?.description).toBe( "Browse and discover public data products on Source.coop" diff --git a/src/app/(app)/products/page.tsx b/src/app/(app)/products/page.tsx index 84811fa13..98f3430df 100644 --- a/src/app/(app)/products/page.tsx +++ b/src/app/(app)/products/page.tsx @@ -3,21 +3,28 @@ import { ProductsList } from "@/components/features/products/ProductsList"; import { ProductsFilters } from "@/components/features/products/ProductsFilters"; import { getPaginatedProducts } from "@/lib/actions/products"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; -export const metadata = { - title: "Products | Source Cooperative", - description: "Browse and discover public data products on Source.coop", - openGraph: { - title: "Products | Source Cooperative", - description: "Browse and discover public data products on Source.coop", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "Products | Source Cooperative", - description: "Browse and discover public data products on Source.coop", - }, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("ProductsPage"); + const title = t("metaTitle"); + const description = t("metaDescription"); + return { + title, + description, + openGraph: { + title, + description, + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + }, + }; +} interface ProductsPageProps { searchParams: Promise<{ @@ -42,31 +49,32 @@ export default async function ProductsPage({ await getPaginatedProducts(100, cursor, previous, undefined, filters); const hasActiveFilters = search || tags || featuredOnly; + const t = await getTranslations("ProductsPage"); return ( - + {hasActiveFilters && ( - Showing {products.length} products + {t("showingCount", { count: products.length })} {search && ( - Search: “{search}” + {t("searchBadge", { search })} )} {tags && ( - Tags: {tags} + {t("tagsBadge", { tags })} )} {featuredOnly && ( - Featured + {t("featured")} )} diff --git a/src/app/(marketing)/CaseStudyCarousel.tsx b/src/app/(marketing)/CaseStudyCarousel.tsx index f1bb60456..d8e4ec069 100644 --- a/src/app/(marketing)/CaseStudyCarousel.tsx +++ b/src/app/(marketing)/CaseStudyCarousel.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { Blockquote, Box, Flex, Link, Text } from "@radix-ui/themes"; import styles from "./Landing.module.css"; @@ -32,6 +33,7 @@ const SLIDE_H = 260; const PEEK = Math.round(SLIDE_H * 0.2); // 52px visible above and below export function CaseStudyCarousel() { + const t = useTranslations("CaseStudyCarousel"); const [index, setIndex] = useState(0); const canPrev = index > 0; @@ -80,7 +82,7 @@ export function CaseStudyCarousel() { rel="noopener noreferrer" size="1" > - Read case study → + {t("readCaseStudy")} ))} @@ -92,7 +94,7 @@ export function CaseStudyCarousel() { ↑ @@ -100,7 +102,7 @@ export function CaseStudyCarousel() { ↓ diff --git a/src/app/(marketing)/HeroGlobe.tsx b/src/app/(marketing)/HeroGlobe.tsx index 4d2f19158..84865eed7 100644 --- a/src/app/(marketing)/HeroGlobe.tsx +++ b/src/app/(marketing)/HeroGlobe.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import dynamic from "next/dynamic"; +import { useTranslations } from "next-intl"; import styles from "./Landing.module.css"; const LiveGlobe = dynamic( @@ -13,6 +14,7 @@ const LiveGlobe = dynamic( ); export function HeroGlobe({ wsUrl }: { wsUrl: string }) { + const t = useTranslations("HeroGlobe"); const [errored, setErrored] = useState(false); const [size, setSize] = useState({ width: 0, height: 0 }); const containerRef = useRef(null); @@ -52,7 +54,7 @@ export function HeroGlobe({ wsUrl }: { wsUrl: string }) { Visualization of live data requests around the world ); } diff --git a/src/app/(marketing)/page.tsx b/src/app/(marketing)/page.tsx index 8561692a9..61e9b0901 100644 --- a/src/app/(marketing)/page.tsx +++ b/src/app/(marketing)/page.tsx @@ -22,6 +22,7 @@ import styles from "./Landing.module.css"; import { productListUrl } from "@/lib/urls"; import { HeroGlobe } from "./HeroGlobe"; import { CONFIG } from "@/lib/config"; +import { getTranslations } from "next-intl/server"; function SectionSubheading({ children }: { children: React.ReactNode }) { return ( @@ -32,6 +33,7 @@ function SectionSubheading({ children }: { children: React.ReactNode }) { } export default async function Landing() { + const t = await getTranslations("LandingPage"); const featuredCount = 4; const featured = await getFeaturedProducts(20); // Shuffle and pick 4 for the homepage @@ -60,15 +62,12 @@ export default async function Landing() { minWidth={{ sm: "60ch" }} > - The Cooperative Data Publishing Utility + {t("heroTitle")} - Data publishing at any scale for everyone. - - - Upload, share, and access data without needing to build or - maintain your own infrastructure. + {t("heroTagline")} + {t("heroBody")} - Read the docs → + {t("readDocs")} @@ -91,7 +90,7 @@ export default async function Landing() { variant="solid" highContrast > - Explore Data Products → + {t("exploreProducts")} @@ -109,18 +108,16 @@ export default async function Landing() {
    - Explore Source Datasets + {t("featuredEyebrow")} - Featured Products + {t("featuredHeading")}
    - - What is Source Cooperative? - + {t("whatIsEyebrow")} - The Challenge + {t("challengeHeading")} - Scientific data infrastructure wasn't built for - cross-border, cross-sector cooperation. + {t("challenge1")}

    - Addressing global challenges means combining data from - governments, research institutions, commercial providers, - and civil society—each with its own formats, access - patterns, and APIs. + {t("challenge2")}

    - Data engineers, scientists, and researchers spend more - time wrangling infrastructure than doing their actual - work, hampering collaboration and limiting informed - decision making. + {t("challenge3")}
    - Our Solution + {t("solutionHeading")} - Source is a nonprofit data publishing utility built on - commodity cloud object storage. Researchers publish data - at any scale without running servers, building portals, or - writing APIs. + {t("solution1")}

    - Source handles cloud infrastructure so researchers can - focus on their data, not their hosting. Users access - everything through standard URLs that work with existing - tools—no custom portals, no proprietary APIs. + {t("solution2")}

    - The service is intentionally commoditized: built to resist - lock-in and designed so that no single provider, funder, - or political decision can make research data disappear. + {t("solution3")}
    @@ -189,11 +171,9 @@ export default async function Landing() { align="start" > - - Why Source is different - + {t("whyEyebrow")} - Data publishing for everyone + {t("whyHeading")} @@ -203,16 +183,13 @@ export default async function Landing() { height={64} lightSrc="/img/ringsIcon.svg" darkSrc="/img/ringsIcon-dark.svg" - alt="icon" + alt={t("iconAlt")} /> - Integrate into any workflow + {t("integrateHeading")} - - Source is a vendor-neutral data publishing utility - that works with any S3-compatible cloud provider. - + {t("integrateBody")} - Neutral hosting, mission backed + {t("neutralHeading")} - - Retain full ownership and control of your data. Source - supports open formats and is a non-commercial - repository, so you're never trapped by - proprietary APIs or workflows. - + {t("neutralBody")} - Flat pricing + {t("pricingHeading")} - Instead of building and maintaining data portals or - trying to wrangle variable cloud costs, Source lets - you focus on the fundamentals that make data easy to - publish and easy to use. + {t("pricing1")}

    - Pricing plans based on data volume and usage will be - announced soon. + {t("pricing2")}
    @@ -292,9 +260,9 @@ export default async function Landing() { align="start" > - Case Studies + {t("caseStudiesEyebrow")} - Open Source, Governed, and Trusted By These Teams + {t("caseStudiesHeading")} @@ -309,19 +277,15 @@ export default async function Landing() { align="start" > - - Built for Data Engineers, Scientists, and Developers - + {t("builtForHeading")} - Source Cooperative supports your data lifecycle, from - prototyping to public release, without navigating corporate - cloud policies. + {t("builtForBody")} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4e4e8e9e5..b81d212a2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,7 +5,7 @@ import { SessionProvider } from "@ory/elements-react/client"; import NextTopLoader from "nextjs-toploader"; import { IBM_Plex_Sans } from "next/font/google"; import { S3CredentialsProvider, UploadProvider } from "@/components"; -import { metadata } from "./metadata"; +import { generateMetadata } from "./metadata"; import { CONFIG } from "@/lib/config"; import { NextIntlClientProvider } from "next-intl"; import { getLocale } from "next-intl/server"; @@ -15,7 +15,7 @@ const ibmPlexSans = IBM_Plex_Sans({ variable: "--font-ibm-plex", }); -export { metadata }; +export { generateMetadata }; interface RootLayoutProps { children: React.ReactNode; diff --git a/src/app/metadata.ts b/src/app/metadata.ts index 9765eec11..7cafeb645 100644 --- a/src/app/metadata.ts +++ b/src/app/metadata.ts @@ -1,6 +1,16 @@ import type { Metadata } from 'next'; +import { getTranslations } from "next-intl/server"; import { CONFIG } from "@/lib"; +/** + * The site description follows the reader's locale; the OpenGraph and Twitter + * cards stay in English, since crawlers fetch them without a language. + */ +export async function generateMetadata(): Promise { + const t = await getTranslations("SiteMetadata"); + return { ...metadata, description: t("description") }; +} + export const metadata: Metadata = { title: { default: "Source Cooperative", diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index aaad98b5c..3a60c351a 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -7,10 +7,11 @@ jest.mock("@/lib/actions/products", () => ({ getPaginatedProducts: jest.fn().mockResolvedValue({ products: [] }), })); -import { metadata } from "./(app)/products/page"; +import { generateMetadata } from "./(app)/products/page"; describe("Products Page Metadata", () => { - it("has proper OpenGraph metadata", () => { + it("has proper OpenGraph metadata", async () => { + const metadata = await generateMetadata(); expect(metadata.title).toBe("Products | Source Cooperative"); expect(metadata.description).toBe( "Browse and discover public data products on Source.coop" @@ -20,12 +21,13 @@ describe("Products Page Metadata", () => { expect(metadata.openGraph?.description).toBe( "Browse and discover public data products on Source.coop" ); - expect(metadata.openGraph?.type).toBe("website"); + expect(metadata.openGraph).toHaveProperty("type", "website"); }); - it("has proper Twitter Card metadata", () => { + it("has proper Twitter Card metadata", async () => { + const metadata = await generateMetadata(); expect(metadata.twitter).toBeDefined(); - expect(metadata.twitter?.card).toBe("summary_large_image"); + expect(metadata.twitter).toHaveProperty("card", "summary_large_image"); expect(metadata.twitter?.title).toBe("Products | Source Cooperative"); expect(metadata.twitter?.description).toBe( "Browse and discover public data products on Source.coop" diff --git a/src/components/core/AccountInfoHoverCard.tsx b/src/components/core/AccountInfoHoverCard.tsx index ef972a0ca..55ea4104e 100644 --- a/src/components/core/AccountInfoHoverCard.tsx +++ b/src/components/core/AccountInfoHoverCard.tsx @@ -1,4 +1,5 @@ import { Badge, Text, Flex, HoverCard } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { Account, isServiceAccount } from "@/types"; import { AccountIdentity, accountCardSurface } from "./AccountIdentity"; // Deep import, not the `../features/profiles` barrel: that barrel also @@ -20,6 +21,7 @@ export function AccountInfoHoverCard({ showHoverCard = true, isLink = true, }: AccountInfoHoverCardProps) { + const t = useTranslations("AccountInfoHoverCard"); if (!showHoverCard) { return <>{children}; } @@ -48,7 +50,7 @@ export function AccountInfoHoverCard({ {isServiceAccount(account) && ( - Service account + {t("serviceAccount")} )} diff --git a/src/components/core/AccountSearchInput.tsx b/src/components/core/AccountSearchInput.tsx index b7f0635c7..2a866a9e6 100644 --- a/src/components/core/AccountSearchInput.tsx +++ b/src/components/core/AccountSearchInput.tsx @@ -7,6 +7,7 @@ import { Avatar, Badge, Box, Flex, Spinner, TextField, Theme } from "@radix-ui/t // inside it disappears. Same package Themes builds its own Popover on, and the // repo already depends on four other @radix-ui/react-* primitives directly. import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { useTranslations } from "next-intl"; import { searchAccounts } from "@/lib/actions/account"; import type { AccountSuggestion } from "@/lib/clients/database/accounts"; import { AccountType } from "@/types"; @@ -44,6 +45,7 @@ export function AccountSearchInput({ memberOf, ...controlProps }: AccountSearchInputProps) { + const t = useTranslations("AccountSearchInput"); const listId = useId(); const optionId = (index: number) => `${listId}-option-${index}`; @@ -176,7 +178,7 @@ export function AccountSearchInput({ - Service account + {t("serviceAccount")} )} diff --git a/src/components/core/ConditionalGroup.tsx b/src/components/core/ConditionalGroup.tsx index c9ceda615..fca19b03c 100644 --- a/src/components/core/ConditionalGroup.tsx +++ b/src/components/core/ConditionalGroup.tsx @@ -1,4 +1,5 @@ import { Box, Flex, Text } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; /** * Fields that exist because of a choice made above them. @@ -19,6 +20,7 @@ export function ConditionalGroup({ because: string; children: React.ReactNode; }) { + const t = useTranslations("ConditionalGroup"); return ( @@ -31,7 +33,7 @@ export function ConditionalGroup({ letterSpacing: "0.1em", }} > - Because {because} + {t("because", { because })} {children} diff --git a/src/components/core/CopyToClipboard.tsx b/src/components/core/CopyToClipboard.tsx index b8268c5c3..84b77f657 100644 --- a/src/components/core/CopyToClipboard.tsx +++ b/src/components/core/CopyToClipboard.tsx @@ -2,12 +2,14 @@ import { useState } from "react"; import { CheckIcon, CopyIcon } from "@radix-ui/react-icons"; import { IconButton, Tooltip } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; interface CopyToClipboardProps { text: string | undefined; } export function CopyToClipboard({ text }: CopyToClipboardProps) { + const t = useTranslations("CopyToClipboard"); const [copied, setCopied] = useState(false); const copyToClipboard = (text: string | undefined) => { navigator.clipboard.writeText(text || "").then(() => { @@ -21,14 +23,14 @@ export function CopyToClipboard({ text }: CopyToClipboardProps) { }; return ( - + copyToClipboard(text)} - aria-label="Copy to clipboard" + aria-label={t("copyToClipboard")} > {copied ? : } diff --git a/src/components/core/DangerZone.tsx b/src/components/core/DangerZone.tsx index 2ffa6d46f..30c2c35c3 100644 --- a/src/components/core/DangerZone.tsx +++ b/src/components/core/DangerZone.tsx @@ -1,4 +1,5 @@ import { Box, Flex, Text } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { SectionHeader } from "./SectionHeader"; interface DangerZoneProps { @@ -24,12 +25,13 @@ export function DangerZone({ action, note, }: DangerZoneProps) { + const t = useTranslations("DangerZone"); return ( // Its own top margin, now that SectionHeader carries none: this sits at the // end of a page rather than inside a form's wrapper, so nothing else is // separating it from what comes before. - + >({ fields, action, disabled, - submitButtonText = "Save", + // Unset falls through to FormActions' own default label. + submitButtonText, hiddenFields = {}, className, initialValues, diff --git a/src/components/core/EditButton.tsx b/src/components/core/EditButton.tsx index 72ec93cfe..c63e0cfd7 100644 --- a/src/components/core/EditButton.tsx +++ b/src/components/core/EditButton.tsx @@ -1,6 +1,7 @@ import { IconButton } from "@radix-ui/themes"; import Link from "next/link"; import { GearIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; interface EditButtonProps { href: string; @@ -13,12 +14,15 @@ export function EditButton({ href, variant = "ghost", size = "2", - children = , + children, }: EditButtonProps) { + const t = useTranslations("Common"); return ( - {children} + {children ?? ( + + )} ); diff --git a/src/components/core/Field.tsx b/src/components/core/Field.tsx index 2d2e366f2..2d3c24dd7 100644 --- a/src/components/core/Field.tsx +++ b/src/components/core/Field.tsx @@ -2,6 +2,7 @@ import { useId } from "react"; import { Flex, Text } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; export interface FieldProps { /** Visible label. Omit only for a control that labels itself (a checkbox group uses `legend`). */ @@ -74,6 +75,7 @@ export function Field({ group, children, }: FieldProps) { + const t = useTranslations("Field"); const generatedId = useId(); const id = htmlFor ?? generatedId; const labelId = `${id}-label`; @@ -90,7 +92,7 @@ export function Field({ - (required) + {t("required")} )} diff --git a/src/components/core/FormActions.tsx b/src/components/core/FormActions.tsx index edc8065b2..336dc2d5b 100644 --- a/src/components/core/FormActions.tsx +++ b/src/components/core/FormActions.tsx @@ -1,4 +1,5 @@ import { Button, Flex, Text } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; interface FormActionsProps { /** @@ -22,13 +23,14 @@ interface FormActionsProps { /** The single action row a form ends with. */ export function FormActions({ - submitLabel = "Save", + submitLabel, pending, disabled, secondary, message, success, }: FormActionsProps) { + const t = useTranslations("Common"); return ( @@ -50,7 +52,7 @@ export function FormActions({ disabled={disabled || pending} loading={pending} > - {submitLabel} + {submitLabel ?? t("save")} diff --git a/src/components/core/LoginButton.tsx b/src/components/core/LoginButton.tsx index 13a0d2dc0..0352c09ae 100644 --- a/src/components/core/LoginButton.tsx +++ b/src/components/core/LoginButton.tsx @@ -3,6 +3,7 @@ import { Button } from "@radix-ui/themes"; import { ReactNode, useEffect, useState } from "react"; import { usePathname, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; import { loginUrl } from "@/lib/urls"; /** @@ -18,10 +19,11 @@ import { loginUrl } from "@/lib/urls"; * first frame returns to Ory's default rather than the current page. */ export function LoginButton({ - children = "Log In / Register", + children, }: { children?: ReactNode; }) { + const t = useTranslations("LoginButton"); const pathname = usePathname(); const searchParams = useSearchParams(); const [href, setHref] = useState(loginUrl()); @@ -31,7 +33,7 @@ export function LoginButton({ return ( ); } diff --git a/src/components/core/LoginRequired.tsx b/src/components/core/LoginRequired.tsx index e81b85ccb..e73b52a09 100644 --- a/src/components/core/LoginRequired.tsx +++ b/src/components/core/LoginRequired.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import { StatusPage } from "./StatusPage"; import { LoginButton } from "./LoginButton"; @@ -7,10 +8,11 @@ import { LoginButton } from "./LoginButton"; * computed client-side (see LoginButton) instead of via middleware headers. */ export function LoginRequired() { + const t = useTranslations("LoginRequired"); return ( Sign in} + action={{t("signIn")}} /> ); } diff --git a/src/components/core/SecretField.tsx b/src/components/core/SecretField.tsx index 00582ed3d..e1a286ec6 100644 --- a/src/components/core/SecretField.tsx +++ b/src/components/core/SecretField.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Button, Flex, Text, TextField } from "@radix-ui/themes"; import { CheckIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; import { Field } from "./Field"; /** @@ -35,6 +36,7 @@ export function SecretField({ errors?: string[]; defaultValue: string; }) { + const t = useTranslations("SecretField"); // Open when there is nothing stored to keep. // // The defaultValue clause only matters on a remount — switching auth type away @@ -66,7 +68,7 @@ export function SecretField({ color="gray" onClick={() => setReplacing(false)} > - Keep current + {t("keepCurrent")} )} @@ -90,7 +92,7 @@ export function SecretField({ - Stored + {t("stored")} diff --git a/src/components/core/StatusPage.tsx b/src/components/core/StatusPage.tsx index 7c4604faa..a3945c7d7 100644 --- a/src/components/core/StatusPage.tsx +++ b/src/components/core/StatusPage.tsx @@ -13,6 +13,7 @@ import { PersonIcon, } from "@radix-ui/react-icons"; import { ReactNode } from "react"; +import { useTranslations } from "next-intl"; type StatusType = "not-found" | "not-authorized" | "unauthenticated"; @@ -32,38 +33,20 @@ interface StatusPageProps { const statusConfig = { "not-found": { icon: LinkBreak2Icon, - defaultTitle: "404: Not Found", - defaultDescription: ( - <> - We couldn't find what you're looking for. -
    - The path may have been moved or no longer exists. - - ), + titleKey: "notFoundTitle", + descriptionKey: "notFoundDescription", }, "not-authorized": { icon: LockClosedIcon, - defaultTitle: "403: Not Authorized", - defaultDescription: ( - <> - You don't have permission to access this resource. -
    - Please contact an administrator if you believe this is an error. - - ), + titleKey: "notAuthorizedTitle", + descriptionKey: "notAuthorizedDescription", }, unauthenticated: { icon: PersonIcon, - defaultTitle: "Sign in required", - defaultDescription: ( - <> - You need to sign in to access this page. -
    - You'll be returned here after logging in. - - ), + titleKey: "unauthenticatedTitle", + descriptionKey: "unauthenticatedDescription", }, -}; +} as const; export function StatusPage({ type, @@ -76,12 +59,14 @@ export function StatusPage({ containerSize, minHeight = "60vh", }: StatusPageProps) { + const t = useTranslations("StatusPage"); const config = statusConfig[type]; const IconComponent = config.icon; - const finalTitle = title || config.defaultTitle; - const finalDescription = description || config.defaultDescription; - const finalActionText = actionText || "Return to Homepage"; + const finalTitle = title || t(config.titleKey); + const finalDescription = + description || t.rich(config.descriptionKey, { br: () =>
    }); + const finalActionText = actionText || t("returnHome"); const finalActionHref = actionHref || "/"; return ( diff --git a/src/components/display/BreadcrumbNav.tsx b/src/components/display/BreadcrumbNav.tsx index 378f6b5f7..3a84234f7 100644 --- a/src/components/display/BreadcrumbNav.tsx +++ b/src/components/display/BreadcrumbNav.tsx @@ -3,6 +3,7 @@ import { Flex } from "@radix-ui/themes"; import Link from "next/link"; import { ChevronRightIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; import { MonoText } from "@/components/core"; interface BreadcrumbNavProps { @@ -16,6 +17,7 @@ export function BreadcrumbNav({ fileName, baseUrl = "", }: BreadcrumbNavProps) { + const t = useTranslations("BreadcrumbNav"); const isRoot = path.length === 0 && !fileName; // Create link styling that doesn't affect layout @@ -100,11 +102,11 @@ export function BreadcrumbNav({
    {isRoot ? ( - root + {t("root")} ) : ( - root + {t("root")} )}
    diff --git a/src/components/display/DateText.tsx b/src/components/display/DateText.tsx index 81ccead0a..cc1768c01 100644 --- a/src/components/display/DateText.tsx +++ b/src/components/display/DateText.tsx @@ -1,7 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { formatDate, formatDateSSR } from '@/lib/format'; +import { useFormatter } from 'next-intl'; interface DateTextProps { date: string; @@ -9,16 +8,20 @@ interface DateTextProps { } export function DateText({ date, includeTime = false }: DateTextProps) { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - // Use SSR-safe format for initial render to avoid jitter - if (!mounted) { - return {formatDateSSR(date)}; - } - - return {formatDate(date, includeTime)}; -} \ No newline at end of file + const format = useFormatter(); + return ( + + {format.dateTime(new Date(date), { + day: 'numeric', + month: 'short', + year: 'numeric', + ...(includeTime && { + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + timeZoneName: 'short', + }), + })} + + ); +} diff --git a/src/components/features/accounts/AccountFlagsForm.tsx b/src/components/features/accounts/AccountFlagsForm.tsx index cc764d54e..ef2af4805 100644 --- a/src/components/features/accounts/AccountFlagsForm.tsx +++ b/src/components/features/accounts/AccountFlagsForm.tsx @@ -7,6 +7,7 @@ import { DynamicForm } from "@/components/core"; import { updateAccountFlags } from "@/lib/actions/account"; import { isAdmin, isAuthorized } from "@/lib/api/authz"; import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; interface AccountFlagsFormProps { session: UserSession; @@ -19,6 +20,7 @@ type AccountFlagsFormData = { }; export function AccountFlagsForm({ session, account }: AccountFlagsFormProps) { + const t = useTranslations("AccountFlagsForm"); // Create initial values for the form dynamically const initialValues = Object.fromEntries( Object.values(AccountFlags).map((flag) => [ @@ -53,43 +55,43 @@ export function AccountFlagsForm({ session, account }: AccountFlagsFormProps) { ? [ [ AccountFlags.CREATE_DATA_CONNECTIONS, - "Create Data Connections", - "Allows this organization to create and manage its own data connections to external storage.", + t("createDataConnections"), + t("createDataConnectionsOrgDescription"), ], [ AccountFlags.CREATE_SERVICE_ACCOUNTS, - "Create Service Accounts", - "Allows this organization to create and manage service accounts for its software and pipelines.", + t("createServiceAccounts"), + t("createServiceAccountsOrgDescription"), ], ] : [ [ AccountFlags.CREATE_REPOSITORIES, - "Create Products", - "Allows this account to create new products and manage product settings.", + t("createProducts"), + t("createProductsDescription"), ], [ AccountFlags.CREATE_ORGANIZATIONS, - "Create Organizations", - "Allows this account to create new organizations and manage organizational accounts.", + t("createOrganizations"), + t("createOrganizationsDescription"), ], [ AccountFlags.CREATE_DATA_CONNECTIONS, - "Create Data Connections", - "Allows this account to create and manage its own data connections to external storage.", + t("createDataConnections"), + t("createDataConnectionsDescription"), ], [ AccountFlags.CREATE_SERVICE_ACCOUNTS, - "Create Service Accounts", - "Allows this account to create and manage service accounts for its software and pipelines.", + t("createServiceAccounts"), + t("createServiceAccountsDescription"), ], ]; if (!isOrganization && isAdmin(session)) { fields.push([ AccountFlags.ADMIN, - "Administrator", - "Full administrative access to the platform. Can manage all accounts, products, and system settings.", + t("admin"), + t("adminDescription"), ]); } diff --git a/src/components/features/accounts/InviteMemberForm.tsx b/src/components/features/accounts/InviteMemberForm.tsx index 9454c672d..0d147bf93 100644 --- a/src/components/features/accounts/InviteMemberForm.tsx +++ b/src/components/features/accounts/InviteMemberForm.tsx @@ -10,6 +10,7 @@ import { } from "@/components/core"; import { inviteMember } from "@/lib/actions/memberships"; import { PlusIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; interface InviteMemberFormProps { organization: Account; @@ -26,38 +27,39 @@ export function InviteMemberForm({ organization, product, }: InviteMemberFormProps) { + const t = useTranslations("InviteMemberForm"); + const tCommon = useTranslations("Common"); const [open, setOpen] = useState(false); const fields: FormField[] = [ { - label: "Account", + label: t("account"), name: "account_id", type: "custom", required: true, - description: - "Search by username or name, or type an account ID. The organization's service accounts are offered too.", + description: t("accountDescription"), customComponent: (controlProps) => ( ), }, { - label: "Role", + label: t("role"), name: "role", type: "select", required: true, - placeholder: "Select a role", - description: "The role to assign to the new member", + placeholder: t("rolePlaceholder"), + description: t("roleDescription"), options: [ - { value: MembershipRole.ReadData, label: "Reader" }, - { value: MembershipRole.WriteData, label: "Writer" }, - { value: MembershipRole.Maintainers, label: "Maintainer" }, - { value: MembershipRole.Owners, label: "Owner" }, + { value: MembershipRole.ReadData, label: t("roleReader") }, + { value: MembershipRole.WriteData, label: t("roleWriter") }, + { value: MembershipRole.Maintainers, label: t("roleMaintainer") }, + { value: MembershipRole.Owners, label: t("roleOwner") }, ], }, ]; @@ -72,13 +74,13 @@ export function InviteMemberForm({ - Invite New Member + {t("title")} - Invite a user to join {organization.name} as a member. + {t("description", { name: organization.name })} {/* Cancel goes through the form's own action row — as a sibling of the @@ -86,7 +88,7 @@ export function InviteMemberForm({ fields={fields} action={inviteMember} - submitButtonText="Send invitation" + submitButtonText={t("submit")} initialValues={initialValues} hiddenFields={{ organization_id: product @@ -98,7 +100,7 @@ export function InviteMemberForm({ secondaryAction={ } diff --git a/src/components/features/admin/AdminUserLookup.tsx b/src/components/features/admin/AdminUserLookup.tsx index 92c4d8ad4..1eef8240f 100644 --- a/src/components/features/admin/AdminUserLookup.tsx +++ b/src/components/features/admin/AdminUserLookup.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { useTranslations } from "next-intl"; import { Avatar, Badge, Box, Flex, Text } from "@radix-ui/themes"; import { AccountIdentity, @@ -21,13 +22,14 @@ interface AdminUserLookupProps { * since "not found" means something different from Ory than from the database. */ export function AdminUserLookup({ query, search }: AdminUserLookupProps) { + const t = useTranslations("AdminUserLookup"); return ( {search && ( - {describe(search, query)} + {describe(t, search, query)} {search.results.map((user) => ( @@ -56,7 +58,7 @@ export function AdminUserLookup({ query, search }: AdminUserLookupProps) { /> } /> - {user.disabled && Disabled} + {user.disabled && {t("disabled")}} ))} @@ -68,16 +70,17 @@ export function AdminUserLookup({ query, search }: AdminUserLookupProps) { ); } -function describe(search: UserSearch, query: string): string { - const q = `“${query}”`; +function describe( + t: ReturnType>, + search: UserSearch, + query: string +): string { if (search.source === "ory") { - if (!search.identityFound) - return `Ory has no identity with the email ${q}.`; - if (search.results.length === 0) - return `Ory has an identity with the email ${q}, but it has no source.coop profile.`; - return `Resolved the email ${q} through Ory.`; + if (!search.identityFound) return t("oryNoIdentity", { query }); + if (search.results.length === 0) return t("oryNoProfile", { query }); + return t("oryResolved", { query }); } return search.results.length === 0 - ? `No account handle or display name in the database contains ${q}.` - : `Handles and display names in the database containing ${q}.`; + ? t("databaseNoMatch", { query }) + : t("databaseMatches", { query }); } diff --git a/src/components/features/admin/AdminUserSearchField.tsx b/src/components/features/admin/AdminUserSearchField.tsx index d8060ae5c..2138e7d26 100644 --- a/src/components/features/admin/AdminUserSearchField.tsx +++ b/src/components/features/admin/AdminUserSearchField.tsx @@ -10,6 +10,7 @@ import { import { usePathname, useRouter } from "next/navigation"; import { Box, Spinner, TextField } from "@radix-ui/themes"; import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; import { useDebounce } from "@/hooks/useDebounce"; interface AdminUserSearchFieldProps { @@ -33,6 +34,7 @@ export function AdminUserSearchField({ query, children, }: AdminUserSearchFieldProps) { + const t = useTranslations("AdminUserSearchField"); const router = useRouter(); const pathname = usePathname(); const [value, setValue] = useState(query); @@ -68,8 +70,8 @@ export function AdminUserSearchField({ setValue(e.target.value)} - placeholder="username, name, or user@example.com" - aria-label="Search users" + placeholder={t("placeholder")} + aria-label={t("ariaLabel")} autoFocus > diff --git a/src/components/features/admin/tools.ts b/src/components/features/admin/tools.ts index f8c5bbd96..a145c9d91 100644 --- a/src/components/features/admin/tools.ts +++ b/src/components/features/admin/tools.ts @@ -18,28 +18,25 @@ type IconProps = ComponentProps; * account dropdown's Admin submenu. */ export interface AdminTool { - name: string; - description: string; + /** The tool's name is `AdminTools..name` in the message catalog. */ + key: "analytics" | "userLookup" | "dataConnections"; href: string; Icon: ComponentType; } export const ADMIN_TOOLS: AdminTool[] = [ { - name: "Analytics", - description: "Explore which accounts and products serve the most traffic.", + key: "analytics", href: adminAnalyticsUrl(), Icon: BarChartIcon, }, { - name: "User Lookup", - description: "Find a user by username, name, or email and open their profile.", + key: "userLookup", href: adminUserLookupUrl(), Icon: MagnifyingGlassIcon, }, { - name: "Data Connections", - description: "Manage the storage backends products mirror their data to.", + key: "dataConnections", href: adminDataConnectionsUrl(), Icon: Link1Icon, }, diff --git a/src/components/features/analytics/AdminBreakdownChart.tsx b/src/components/features/analytics/AdminBreakdownChart.tsx index 073e154bb..d680641ed 100644 --- a/src/components/features/analytics/AdminBreakdownChart.tsx +++ b/src/components/features/analytics/AdminBreakdownChart.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import { useFormatter, useTranslations } from "next-intl"; import { Box, Dialog, @@ -27,7 +28,7 @@ import { } from "recharts"; import { formatBytes } from "@/lib/format"; import { parseActiveIndex, Stat, StatRow } from "./panels"; -import { HELP, mono } from "./style"; +import { mono } from "./style"; import { seriesColor } from "./palette"; type Metric = "bytes" | "requests"; @@ -52,30 +53,11 @@ interface AdminBreakdownChartProps { queries?: string[]; } -const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "); const DAY_MIN = 1440; const DAY_MS = 86_400_000; -const hhmm = (d: Date) => - `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; - -function tickLabel(iso: string, bucketMinutes: number): string { - const d = new Date(iso); - const day = `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; - // Sub-daily buckets need the time too; daily+ buckets always start at - // 00:00 UTC, so the time would be noise. - return bucketMinutes < DAY_MIN ? `${day} ${hhmm(d)}` : day; -} - -function bucketLabel(iso: string, bucketMinutes: number): string { - const d = new Date(iso); - const day = `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; - return bucketMinutes < DAY_MIN - ? `${day} ${hhmm(d)} UTC` - : bucketMinutes > DAY_MIN - ? `${day} + ${bucketMinutes / DAY_MIN - 1}d` - : day; -} +const DAY = { day: "numeric", month: "short" } as const; +const TIME = { hour: "2-digit", minute: "2-digit", hourCycle: "h23" } as const; /** * Zooming in: the interval shown after drilling into one bucket of a given @@ -92,27 +74,10 @@ const DRILL_INTERVAL: Record = { 1: 1, }; -// en-US explicitly: this renders in SSR HTML, and locale-following -// toLocaleString() would hydrate differently for non-en visitors. -const compact = new Intl.NumberFormat("en-US", { notation: "compact" }); -const plain = new Intl.NumberFormat("en-US"); - -function formatMetric(value: number, metric: Metric): string { - return metric === "bytes" ? formatBytes(value) : compact.format(value); -} - /** formatBytes chokes on sub-1 values (negative log); pin those to bytes. */ const byteRate = (perSec: number) => `${perSec > 0 && perSec < 1 ? `${perSec.toFixed(2)} B` : formatBytes(perSec, 1)}/s`; -/** Average rate over one bucket: "0.43/s" requests, "12.3 MB/s" bytes. */ -function rate(value: number, bucketMinutes: number, metric: Metric): string { - const perSec = value / (bucketMinutes * 60); - return metric === "bytes" - ? byteRate(perSec) - : `${perSec >= 10 ? compact.format(perSec) : perSec.toFixed(2)}/s`; -} - /** * Stacked bar timeseries of traffic, one bar per time bucket, stacked by the * page's group-by series. Bytes/requests toggle is client-side — both metrics @@ -131,6 +96,42 @@ export function AdminBreakdownChart({ }: AdminBreakdownChartProps) { const [metric, setMetric] = useState(initialMetric); const router = useRouter(); + const t = useTranslations("AdminBreakdownChart"); + const tStat = useTranslations("AnalyticsPanels"); + const tHelp = useTranslations("AnalyticsHelp"); + const format = useFormatter(); + const compact = (value: number) => + format.number(value, { notation: "compact" }); + + const formatMetric = (value: number, metric: Metric): string => + metric === "bytes" ? formatBytes(value) : compact(value); + + /** Average rate over one bucket: "0.43/s" requests, "12.3 MB/s" bytes. */ + const rate = (value: number, bucketMinutes: number, metric: Metric) => { + const perSec = value / (bucketMinutes * 60); + return metric === "bytes" + ? byteRate(perSec) + : `${perSec >= 10 ? compact(perSec) : perSec.toFixed(2)}/s`; + }; + + // Sub-daily buckets need the time too; daily+ buckets always start at + // 00:00 UTC, so the time would be noise. + const tickLabel = (iso: string, bucketMinutes: number) => + format.dateTime( + new Date(iso), + bucketMinutes < DAY_MIN ? { ...DAY, ...TIME } : DAY, + ); + + const bucketLabel = (iso: string, bucketMinutes: number) => { + const d = new Date(iso); + const day = format.dateTime(d, { ...DAY, year: "numeric" }); + return bucketMinutes < DAY_MIN + ? t("bucketUtc", { date: format.dateTime(d, { ...DAY, year: "numeric", ...TIME }) }) + : bucketMinutes > DAY_MIN + ? t("bucketPlusDays", { date: day, days: bucketMinutes / DAY_MIN - 1 }) + : day; + }; + const seriesName = (key: string) => (key === otherKey ? t("other") : key); // History back/forward re-renders this mounted component with a different // ?metric= — resync the local (instant-toggle) copy. @@ -237,29 +238,29 @@ export function AdminBreakdownChart({ > @@ -268,22 +269,24 @@ export function AdminBreakdownChart({ value={metric} onValueChange={(value) => changeMetric(value as Metric)} > - Bytes + + {t("bytes")} + - Requests + {tStat("requests")} {queries.length > 0 && ( - + - Analytics Engine SQL + {t("sqlTitle")} - The statements that produced this view, in execution order. + {t("sqlDescription")} {queries.map((sql, i) => ( {fullscreen ? : } @@ -411,7 +414,7 @@ export function AdminBreakdownChart({ truncate style={{ flex: 1, minWidth: 0 }} > - {series[entry.index]} + {seriesName(series[entry.index])} {formatMetric(entry.value, metric)} @@ -426,7 +429,7 @@ export function AdminBreakdownChart({ - Total + {t("total")} {formatMetric(total, metric)} @@ -474,15 +477,13 @@ export function AdminBreakdownChart({ style={{ background: colorAt(s) }} /> - {key} + {seriesName(key)} ))} - Times are UTC. Values are estimates — high-volume traffic is measured - from a sample. Click a bar (or drag across several) to zoom in; the - browser back button zooms out. + {t("footnote")} ); diff --git a/src/components/features/analytics/AdminFiltersForm.tsx b/src/components/features/analytics/AdminFiltersForm.tsx index 9ed1359e8..59372a0c3 100644 --- a/src/components/features/analytics/AdminFiltersForm.tsx +++ b/src/components/features/analytics/AdminFiltersForm.tsx @@ -11,16 +11,23 @@ import { TextField, } from "@radix-ui/themes"; import { Cross2Icon, PlusIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; import { MonoLabel } from "./panels"; const DEBOUNCE_MS = 500; -/** Value-input hints per dimension key (labels come from the page). */ -const PLACEHOLDER: Record = { - account: "account id", - product: "product id", - country: "country code (US)", - client: "ip hash prefix", +/** Value-input hint message keys per dimension key (labels come from the page). */ +const PLACEHOLDER: Record< + string, + | "placeholderAccount" + | "placeholderProduct" + | "placeholderCountry" + | "placeholderClient" +> = { + account: "placeholderAccount", + product: "placeholderProduct", + country: "placeholderCountry", + client: "placeholderClient", }; interface AdminFiltersFormProps { @@ -47,6 +54,7 @@ export function AdminFiltersForm({ defaults, hidden, }: AdminFiltersFormProps) { + const t = useTranslations("AdminFiltersForm"); const form = useRef(null); const timer = useRef | undefined>(undefined); const [pending, setPending] = useState(false); @@ -100,7 +108,7 @@ export function AdminFiltersForm({ type="datetime-local" name="from" defaultValue={defaults.from} - aria-label="From (UTC)" + aria-label={t("fromAria")} onChange={submitSoon} /> - UTC · end exclusive + {t("utcHint")} - Filter by + {t("filterBy")} {rows.map((row, index) => ( @@ -127,7 +135,7 @@ export function AdminFiltersForm({ value={row.dim} onValueChange={(dim) => setRow(index, { ...row, dim })} > - + {dimensions.map((d) => ( removeRow(index)} > @@ -177,10 +185,10 @@ export function AdminFiltersForm({ onClick={addRow} disabled={rows.length >= dimensions.length} > - Add filter + {t("addFilter")} diff --git a/src/components/features/analytics/ProductAnalyticsView.tsx b/src/components/features/analytics/ProductAnalyticsView.tsx index 05830668c..991d08bd0 100644 --- a/src/components/features/analytics/ProductAnalyticsView.tsx +++ b/src/components/features/analytics/ProductAnalyticsView.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Box, Flex, Grid, Table, Tabs, Text } from "@radix-ui/themes"; import Link from "next/link"; +import { useFormatter, useLocale, useTranslations } from "next-intl"; import type { ProductBreakdowns, UsagePoint, @@ -13,11 +14,9 @@ import { formatBytes } from "@/lib/format"; import { objectUrl } from "@/lib/urls"; import { DownloadsChart, - HELP, HoverCaption, MonoLabel, mono, - numberFormat, Stat, StatRow, UsersContent, @@ -46,6 +45,22 @@ export function ProductAnalyticsView({ breakdowns, }: ProductAnalyticsViewProps) { const [hovered, setHovered] = useState(null); + const t = useTranslations("ProductAnalyticsView"); + const tStat = useTranslations("AnalyticsPanels"); + const tHelp = useTranslations("AnalyticsHelp"); + const format = useFormatter(); + // The data layer names countries in English; render them in the reader's + // language instead. Non-ISO codes (e.g. "T1") keep the data layer's name. + const locale = useLocale(); + const regionNames = new Intl.DisplayNames([locale], { type: "region" }); + const countryName = (code: string, fallback: string) => { + if (code === "??") return t("unknownCountry"); + try { + return regionNames.of(code) || fallback; + } catch { + return fallback; + } + }; const shown = hovered === null ? totals : days[hovered]; const maxCountry = Math.max( 1, @@ -58,12 +73,12 @@ export function ProductAnalyticsView({ - DOWNLOADS + {t("downloadsTab")} - USERS + {t("usersTab")} @@ -71,24 +86,24 @@ export function ProductAnalyticsView({ @@ -104,24 +119,26 @@ export function ProductAnalyticsView({ - By country + {t("byCountry")} {!breakdowns ? ( - Country breakdown unavailable. + {t("breakdownUnavailable")} ) : ( {[ ...breakdowns.countries.map((c) => ({ code: c.code, - label: c.name, + label: countryName(c.code, c.name), requests: c.requests, })), ...(breakdowns.otherCountries ? [ { code: "·", - label: `${breakdowns.otherCountries.count} others`, + label: t("otherCountries", { + count: breakdowns.otherCountries.count, + }), requests: breakdowns.otherCountries.requests, }, ] @@ -148,7 +165,7 @@ export function ProductAnalyticsView({ {row.label} - {numberFormat.format(Math.round(row.requests))} + {format.number(Math.round(row.requests))} - Top files + {t("topFiles")} {!breakdowns || breakdowns.files.length === 0 ? ( - No file downloads in this period. + {t("noFileDownloads")} ) : ( - File + {t("file")} - Downloads + + {tStat("downloads")} + - Data served + + {tStat("dataServed")} + @@ -205,7 +226,7 @@ export function ProductAnalyticsView({ - {numberFormat.format(Math.round(file.requests))} + {format.number(Math.round(file.requests))} diff --git a/src/components/features/analytics/ProductTabs.tsx b/src/components/features/analytics/ProductTabs.tsx index c3d4db433..4f761e47b 100644 --- a/src/components/features/analytics/ProductTabs.tsx +++ b/src/components/features/analytics/ProductTabs.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { LockClosedIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; import { productAnalyticsUrl, productUrl } from "@/lib/urls"; interface ProductTabsProps { @@ -20,11 +21,12 @@ const label = (active: boolean): React.CSSProperties => ({ * manage the product (the analytics route 404s everyone else). */ export function ProductTabs({ accountId, productId, active }: ProductTabsProps) { + const t = useTranslations("ProductTabs"); const tabs = [ - { key: "product", text: "PRODUCT", href: productUrl(accountId, productId) }, + { key: "product", text: t("product"), href: productUrl(accountId, productId) }, { key: "analytics", - text: "ANALYTICS", + text: t("analytics"), href: productAnalyticsUrl(accountId, productId), }, ] as const; @@ -49,12 +51,12 @@ export function ProductTabs({ accountId, productId, active }: ProductTabsProps) > {tab.key === "analytics" && ( - + )} diff --git a/src/components/features/analytics/UsageCard.tsx b/src/components/features/analytics/UsageCard.tsx index 80279858b..a85ce95b1 100644 --- a/src/components/features/analytics/UsageCard.tsx +++ b/src/components/features/analytics/UsageCard.tsx @@ -1,8 +1,8 @@ import { Card } from "@radix-ui/themes"; +import { getTranslations } from "next-intl/server"; import { SectionHeader } from "@/components/core/SectionHeader"; import { getUsage } from "@/lib/clients/analytics"; import { MonoLabel } from "./panels"; -import { HELP } from "./style"; import { UsagePanel } from "./UsagePanel"; interface UsageCardProps { @@ -20,6 +20,8 @@ interface UsageCardProps { export async function UsageCard({ accountId, productId }: UsageCardProps) { const usage = await getUsage(accountId, productId); if (!usage) return null; + const t = await getTranslations("UsageCard"); + const tHelp = await getTranslations("AnalyticsHelp"); return ( // flexShrink 0: in the grid-stretched meta column an over-constrained @@ -27,9 +29,11 @@ export async function UsageCard({ accountId, productId }: UsageCardProps) { // (Radix Card is overflow:hidden). {usage.days.length} days + + {t("windowDays", { days: usage.days.length })} + } > diff --git a/src/components/features/analytics/UsageCardSkeleton.tsx b/src/components/features/analytics/UsageCardSkeleton.tsx index 7c53be999..62a47730f 100644 --- a/src/components/features/analytics/UsageCardSkeleton.tsx +++ b/src/components/features/analytics/UsageCardSkeleton.tsx @@ -1,4 +1,5 @@ import { Box, Card, Flex, Skeleton } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { SectionHeader } from "@/components/core/SectionHeader"; /** @@ -8,9 +9,10 @@ import { SectionHeader } from "@/components/core/SectionHeader"; * resolves to nothing and the skeleton would flash and vanish. */ export function UsageCardSkeleton() { + const t = useTranslations("UsageCard"); return ( - + diff --git a/src/components/features/analytics/UsagePanel.stories.tsx b/src/components/features/analytics/UsagePanel.stories.tsx index f357d275e..ca35653e0 100644 --- a/src/components/features/analytics/UsagePanel.stories.tsx +++ b/src/components/features/analytics/UsagePanel.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; import { Card } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { SectionHeader } from "@/components/core/SectionHeader"; import type { UsagePoint, UsageTotals } from "@/lib/clients/analytics"; import { MonoLabel } from "./panels"; -import { HELP } from "./style"; import { UsagePanel } from "./UsagePanel"; /** @@ -21,6 +21,12 @@ import { UsagePanel } from "./UsagePanel"; * that got linked from somewhere big on a single day, one serving petabytes. * Hovering a bar swaps the stats row to that day — that part is live here. */ +function WindowLabel({ days }: { days: number }) { + const t = useTranslations("UsageCard"); + const tHelp = useTranslations("AnalyticsHelp"); + return {t("windowDays", { days })}; +} + const meta = { component: UsagePanel, title: "Features/Product page/Analytics/UsagePanel", @@ -31,9 +37,7 @@ const meta = { - {(context.args.days as UsagePoint[]).length} days - + } > diff --git a/src/components/features/analytics/UsagePanel.tsx b/src/components/features/analytics/UsagePanel.tsx index c2c2bdbb4..423d85144 100644 --- a/src/components/features/analytics/UsagePanel.tsx +++ b/src/components/features/analytics/UsagePanel.tsx @@ -2,16 +2,10 @@ import { useState } from "react"; import { Flex } from "@radix-ui/themes"; +import { useFormatter, useTranslations } from "next-intl"; import type { UsagePoint, UsageTotals } from "@/lib/clients/analytics"; import { formatBytes } from "@/lib/format"; -import { - DownloadsChart, - HELP, - HoverCaption, - numberFormat, - Stat, - StatRow, -} from "./panels"; +import { DownloadsChart, HoverCaption, Stat, StatRow } from "./panels"; // Kept here for its existing unit tests / import sites. export { parseActiveIndex } from "./panels"; @@ -30,24 +24,27 @@ interface UsagePanelProps { export function UsagePanel({ days, totals }: UsagePanelProps) { const [hovered, setHovered] = useState(null); const shown = hovered === null ? totals : days[hovered]; + const t = useTranslations("AnalyticsPanels"); + const tHelp = useTranslations("AnalyticsHelp"); + const format = useFormatter(); return ( <> diff --git a/src/components/features/analytics/panels.tsx b/src/components/features/analytics/panels.tsx index 711fa4334..cc11ba433 100644 --- a/src/components/features/analytics/panels.tsx +++ b/src/components/features/analytics/panels.tsx @@ -5,6 +5,7 @@ * product analytics page (ProductAnalyticsView). */ import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { useFormatter, useTranslations } from "next-intl"; import { BarChart, Bar, @@ -19,17 +20,11 @@ import { // Types only — a value import would drag the server data layer (CONFIG, // LOGGER) into the client bundle. import type { UsagePoint, UsageUsers } from "@/lib/clients/analytics"; -import { formatDateSSR } from "@/lib/format"; -import { HELP, mono } from "./style"; +import { mono } from "./style"; -export { HELP, mono }; +export { mono }; -// Deterministic across server and client — no-arg toLocaleString() follows -// the runtime locale and causes hydration mismatches. -export const numberFormat = new Intl.NumberFormat("en-US"); -export const compactFormat = new Intl.NumberFormat("en-US", { - notation: "compact", -}); +const DAY_FORMAT = { day: "numeric", month: "short", year: "numeric" } as const; /** * recharts 3 delivers activeTooltipIndex as a numeric string (or null); @@ -135,6 +130,9 @@ export function HoverCaption({ days: UsagePoint[]; hovered: number | null; }) { + const t = useTranslations("AnalyticsPanels"); + const tHelp = useTranslations("AnalyticsHelp"); + const format = useFormatter(); return ( {hovered !== null && ( @@ -144,10 +142,10 @@ export function HoverCaption({ style={{ background: "var(--green-9)", flexShrink: 0 }} /> )} - + {hovered === null - ? `${days.length}-day downloads` - : formatDateSSR(days[hovered].date)} + ? t("windowDownloads", { days: days.length }) + : format.dateTime(new Date(days[hovered].date), DAY_FORMAT)} ); @@ -165,10 +163,12 @@ export function DownloadsChart({ onHover: (index: number | null) => void; height: number; }) { + const t = useTranslations("AnalyticsPanels"); + const format = useFormatter(); return ( onHover(null)} // Clicking the chart must not move focus (a click inside the Radix // tab panel otherwise promotes focus to it and draws its focus ring @@ -204,8 +204,10 @@ export function DownloadsChart({ - {formatDateSSR(days[0].date)} - {formatDateSSR(days[days.length - 1].date)} + {format.dateTime(new Date(days[0].date), DAY_FORMAT)} + + {format.dateTime(new Date(days[days.length - 1].date), DAY_FORMAT)} + ); @@ -213,31 +215,40 @@ export function DownloadsChart({ /** USERS tab body: unique IPs, registered vs anonymous usage, per-IP histogram. */ export function UsersContent({ users }: { users: UsageUsers }) { + const t = useTranslations("AnalyticsPanels"); + const tHelp = useTranslations("AnalyticsHelp"); + const format = useFormatter(); return ( <> - IPs by download count + + {t("ipsByDownloadCount")} + {users.uniqueIps === 0 ? ( - No download activity in this period. + {t("noActivity")} ) : ( @@ -262,6 +273,8 @@ function DistributionChart({ }: { distribution: UsageUsers["distribution"]; }) { + const t = useTranslations("AnalyticsPanels"); + const format = useFormatter(); const total = distribution.reduce((sum, bin) => sum + bin.ips, 0); // Trim trailing empty bins (a sparse product would waste half the axis); // interior zeros stay — they are part of the distribution's shape. @@ -277,7 +290,7 @@ function DistributionChart({ return ( event.preventDefault()} style={{ userSelect: "none", WebkitUserSelect: "none" }} > @@ -321,10 +334,15 @@ function DistributionChart({ })} > - {numberFormat.format(row.ips)} IPs · {label} downloads + {t("tooltipIps", { ips: format.number(row.ips), label: label ?? "" })} - {(row.share * 100).toFixed(0)}% of IPs at or below this + {t("tooltipShare", { + share: format.number(row.share, { + style: "percent", + maximumFractionDigits: 0, + }), + })} ); @@ -349,7 +367,7 @@ function DistributionChart({ style={{ background: "var(--gray-12)" }} /> - IPs + {t("legendIps")} @@ -360,7 +378,7 @@ function DistributionChart({ style={{ background: "var(--green-9)" }} /> - cumulative + {t("legendCumulative")} diff --git a/src/components/features/analytics/style.ts b/src/components/features/analytics/style.ts index ab0939ad3..aa88605ab 100644 --- a/src/components/features/analytics/style.ts +++ b/src/components/features/analytics/style.ts @@ -1,32 +1,16 @@ // Shared analytics styling constants. Deliberately NOT a "use client" -// module: server components (the admin page) call mono() and read HELP, -// and client-module exports can't be invoked across the RSC boundary. -import type { CSSProperties } from "react"; - -// Reader-facing copy: plain language, no internals. These tooltips are read -// by people publishing data, not by people who know how the numbers are +// module: server components (the admin page) call mono(), and client-module +// exports can't be invoked across the RSC boundary. +// +// Tooltip copy lives in the "AnalyticsHelp" message namespace. It is +// reader-facing: plain language, no internals. These tooltips are read by +// people publishing data, not by people who know how the numbers are // collected — the caveats are stated as what to expect, not as mechanics. -export const HELP = { - downloads: "Number of successful downloads.", - window: - "Dates are UTC days. The range covers the most recent full days plus today so far.", - served: "Total amount of data downloaded.", - bandwidth: "Average rate that data was downloaded over this range.", - requests: "Requests that successfully returned data.", - countries: "How many different countries downloads came from.", - dailyAvg: "Average downloads per day over the period.", - uniqueIps: - "How many different IP addresses downloaded data. Everyone sharing a network — an office, a university, a cloud provider — counts once, and very busy products may count a little low.", - // Product-page uniques are counted in weekly slices to keep them exact; - // the admin explorer usually scans everything at once, so its number is - // measured from a sample and undercounts more the wider the range gets. - uniqueIpsSampled: - "How many different IP addresses appear in this range. Busy ranges are measured from a sample, so this undercounts — the wider the range, the more it undercounts.", - registered: "Downloads by signed-in users.", - anon: "Downloads with no signed-in user.", - distribution: - "How many IP addresses downloaded once, twice, and so on. The line adds them up left to right, so at any bar it shows the share that downloaded that many times or fewer.", -}; +// The admin explorer's unique-IP count ("uniqueIpsSampled") differs from the +// product page's because product-page uniques are counted in weekly slices to +// keep them exact, while the admin explorer usually scans everything at once +// and so measures from a sample that undercounts more the wider the range. +import type { CSSProperties } from "react"; export const mono = (extra?: CSSProperties): CSSProperties => ({ fontFamily: "var(--code-font-family)", diff --git a/src/components/features/auth/EmailVerificationCallout.tsx b/src/components/features/auth/EmailVerificationCallout.tsx index c519212bb..72160f9ca 100644 --- a/src/components/features/auth/EmailVerificationCallout.tsx +++ b/src/components/features/auth/EmailVerificationCallout.tsx @@ -1,6 +1,7 @@ import { Box, Callout, Link } from "@radix-ui/themes"; import { CheckCircledIcon, InfoCircledIcon } from "@radix-ui/react-icons"; import { verifyEmailUrl } from "@/lib"; +import { useTranslations } from "next-intl"; /** * Which banner to render. "unverified" shows a reminder to verify; @@ -15,6 +16,7 @@ interface EmailVerificationCalloutProps { export function EmailVerificationCallout({ status, }: EmailVerificationCalloutProps) { + const t = useTranslations("EmailVerificationCallout"); return ( {status === "unverified" ? ( @@ -23,10 +25,9 @@ export function EmailVerificationCallout({ - Please check your email. We've sent you a code to verify your - email address. Or, you can click{" "} - here to verify your email - address. + {t.rich("unverified", { + link: (chunks) => {chunks}, + })} ) : ( @@ -35,8 +36,7 @@ export function EmailVerificationCallout({ - Your email has been successfully verified. Thank you for confirming - your account. + {t("justVerified")} )} diff --git a/src/components/features/data-connections/ConnectionUsage.tsx b/src/components/features/data-connections/ConnectionUsage.tsx index 2faabef46..1566b0ce3 100644 --- a/src/components/features/data-connections/ConnectionUsage.tsx +++ b/src/components/features/data-connections/ConnectionUsage.tsx @@ -1,5 +1,6 @@ import { Text, Table } from "@radix-ui/themes"; import Link from "next/link"; +import { getTranslations } from "next-intl/server"; import { productsTable } from "@/lib/clients"; import { productUrl } from "@/lib/urls"; import { SectionHeader } from "@/components/core"; @@ -22,16 +23,14 @@ export async function ConnectionUsage({ }) { const products = await productsTable.listProductsByConnectionId(connectionId); - const heading = - products.length === 1 - ? "Used by 1 product" - : `Used by ${products.length} products`; + const t = await getTranslations("ConnectionUsage"); + const heading = t("heading", { count: products.length }); return ( {products.length === 0 ? ( - Nothing uses this connection, so it can be deleted. + {t("unused")} ) : ( // `surface` brings the outer border and panel fill with it, so this @@ -44,9 +43,9 @@ export async function ConnectionUsage({ {/* Radix leaves the header transparent in this variant; the tint is what separates labels from the first row without a second rule. */} - Product - Role - Status + {t("columnProduct")} + {t("columnRole")} + {t("columnStatus")} @@ -87,10 +86,10 @@ export async function ConnectionUsage({ {/* Primary is the one served from, so it is marked; being a mirror is the ordinary case and stays plain. */} {isPrimary ? ( - Primary + {t("primary")} ) : ( - Mirror + {t("mirror")} )} @@ -99,9 +98,9 @@ export async function ConnectionUsage({ Deactivating is a deliberate act, not a fault, and a badge on every row saying "nothing is wrong" is noise. */} {product.disabled ? ( - Deactivated + {t("deactivated")} ) : ( - Active + {t("active")} )} diff --git a/src/components/features/data-connections/ConnectionsEmpty.tsx b/src/components/features/data-connections/ConnectionsEmpty.tsx index 09d25f8e7..611512633 100644 --- a/src/components/features/data-connections/ConnectionsEmpty.tsx +++ b/src/components/features/data-connections/ConnectionsEmpty.tsx @@ -1,8 +1,10 @@ import { Flex, Text } from "@radix-ui/themes"; import { Link1Icon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; /** Shared empty state, so the two lists cannot drift apart again. */ export function ConnectionsEmpty({ children }: { children: React.ReactNode }) { + const t = useTranslations("ConnectionsEmpty"); return ( - No data connections + {t("title")} {children} diff --git a/src/components/features/data-connections/DataConnectionForm.tsx b/src/components/features/data-connections/DataConnectionForm.tsx index fab852ec7..898907573 100644 --- a/src/components/features/data-connections/DataConnectionForm.tsx +++ b/src/components/features/data-connections/DataConnectionForm.tsx @@ -14,6 +14,7 @@ import { } from "@radix-ui/themes"; import { CopyToClipboard } from "@/components/core/CopyToClipboard"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { slugifyToId, DataProvider, @@ -47,27 +48,24 @@ interface DataConnectionFormProps { } // Storage providers limited to those with a `details` schema (S3, Azure, GCS). -const providerOptions: Array<{ - value: DataProvider; - label: string; - description: string; -}> = [ +// Labels are product names and stay untranslated. +const providerOptions = [ { value: DataProvider.S3, label: "AWS S3", - description: "Or an S3-compatible backend: Cloudflare R2, MinIO, Ceph.", + descriptionKey: "providerS3Description", }, { value: DataProvider.Azure, label: "Azure Blob", - description: "A storage account and one of its containers.", + descriptionKey: "providerAzureDescription", }, { value: DataProvider.GCS, label: "Google Cloud", - description: "Keyless: federated through Workload Identity.", + descriptionKey: "providerGcsDescription", }, -]; +] as const; const s3AuthTypes = [ DataConnectionAuthenticationType.S3AccessKey, @@ -87,33 +85,27 @@ const authTypesByProvider: Record = [DataProvider.GCS]: gcpAuthTypes, }; -const AUTH_TYPE_LABELS: Record = { - [DataConnectionAuthenticationType.S3AccessKey]: "Access Key", - [DataConnectionAuthenticationType.S3WebIdentityRole]: - "Web Identity Role (federated)", - [DataConnectionAuthenticationType.AzureSasToken]: "SAS Token", +const AUTH_TYPE_LABELS = { + [DataConnectionAuthenticationType.S3AccessKey]: "authAccessKey", + [DataConnectionAuthenticationType.S3WebIdentityRole]: "authWebIdentityRole", + [DataConnectionAuthenticationType.AzureSasToken]: "authSasToken", [DataConnectionAuthenticationType.AzureWorkloadIdentity]: - "Workload Identity (federated)", - [DataConnectionAuthenticationType.GcpWorkloadIdentity]: - "Workload Identity (federated)", -}; + "authWorkloadIdentity", + [DataConnectionAuthenticationType.GcpWorkloadIdentity]: "authWorkloadIdentity", +} as const; // One-line description of what each authentication type means, shown under the // Authentication Type select so the admin knows what they're choosing. -const AUTH_TYPE_DESCRIPTIONS: Partial< - Record -> = { - [DataConnectionAuthenticationType.S3AccessKey]: - "Static IAM access key and secret you provide.", +const AUTH_TYPE_DESCRIPTIONS = { + [DataConnectionAuthenticationType.S3AccessKey]: "authAccessKeyDescription", [DataConnectionAuthenticationType.S3WebIdentityRole]: - "Keyless: the proxy assumes a customer IAM role via web identity.", - [DataConnectionAuthenticationType.AzureSasToken]: - "A shared access signature token you provide.", + "authWebIdentityRoleDescription", + [DataConnectionAuthenticationType.AzureSasToken]: "authSasTokenDescription", [DataConnectionAuthenticationType.AzureWorkloadIdentity]: - "Keyless: the proxy federates into an Azure AD app registration.", + "authAzureWorkloadIdentityDescription", [DataConnectionAuthenticationType.GcpWorkloadIdentity]: - "Keyless: the proxy federates into a GCP service account via Workload Identity.", -}; + "authGcpWorkloadIdentityDescription", +} as const; /** * Where a product's objects land: backend root, the connection's shared base @@ -142,25 +134,14 @@ export function exampleLocation( // Radix Select has no empty-string item value; this stands in for "unset". const NONE = "__none__"; -const VISIBILITY_LABELS: Record = { - [ProductVisibility.Public]: "Public", - [ProductVisibility.Unlisted]: "Unlisted", - [ProductVisibility.Restricted]: "Restricted", -}; - -// Phrased from the connection's side: which products it will carry. -const VISIBILITY_DESCRIPTIONS: Record = { - [ProductVisibility.Public]: "Products anyone can find and download.", - [ProductVisibility.Unlisted]: "Products reachable by link only.", - [ProductVisibility.Restricted]: "Products limited to their members.", -}; - export function DataConnectionForm({ dataConnection, mode, ownerAccountId, }: DataConnectionFormProps) { const router = useRouter(); + const t = useTranslations("DataConnectionForm"); + const tc = useTranslations("Common"); const action = mode === "create" ? createDataConnection : updateDataConnection; const [state, formAction, pending] = useActionState(action, { @@ -372,11 +353,11 @@ export function DataConnectionForm({ value={dataConnection.data_connection_id} /> )} - + {(props) => ( @@ -396,13 +377,13 @@ export function DataConnectionForm({ up in URLs and as the storage key, so it is shown rather than sprung on the user after saving. */} @@ -414,11 +395,11 @@ export function DataConnectionForm({ - + @@ -452,7 +433,7 @@ export function DataConnectionForm({ {option.label} - {option.description} + {t(option.descriptionKey)} @@ -465,10 +446,10 @@ export function DataConnectionForm({ {/* Provider-specific fields */} {provider === DataProvider.S3 && ( - + {(props) => ( @@ -485,8 +466,8 @@ export function DataConnectionForm({ {(props) => ( @@ -498,7 +479,7 @@ export function DataConnectionForm({ > @@ -513,8 +494,8 @@ export function DataConnectionForm({ {(props) => ( @@ -537,10 +518,10 @@ export function DataConnectionForm({ )} {provider === DataProvider.GCS && ( - + {(props) => ( @@ -559,10 +540,10 @@ export function DataConnectionForm({ )} {provider === DataProvider.Azure && ( - + {(props) => ( @@ -578,8 +559,8 @@ export function DataConnectionForm({ {(props) => ( @@ -596,8 +577,8 @@ export function DataConnectionForm({ {(props) => ( @@ -609,7 +590,7 @@ export function DataConnectionForm({ > @@ -628,18 +609,20 @@ export function DataConnectionForm({
    - + - The methods offered depend on the backend selected above.{" "} - {(authType && - AUTH_TYPE_DESCRIPTIONS[ - authType as DataConnectionAuthenticationType - ]) || - "How the data proxy authenticates to this backend when serving the product's data. Choose None for unsigned (public) access."} + {t("authTypeHelp")}{" "} + {t( + (authType && + AUTH_TYPE_DESCRIPTIONS[ + authType as DataConnectionAuthenticationType + ]) || + "authNoneDescription" + )} } errors={state.fieldErrors?.auth_type} @@ -657,10 +640,10 @@ export function DataConnectionForm({ style={{ width: "100%" }} /> - None (unsigned) + {t("authNone")} {authOptions.map((type) => ( - {AUTH_TYPE_LABELS[type]} + {t(AUTH_TYPE_LABELS[type])} ))} @@ -671,10 +654,10 @@ export function DataConnectionForm({ {/* Auth-specific fields */} {authType === DataConnectionAuthenticationType.S3AccessKey && ( - + {(props) => ( @@ -694,8 +677,8 @@ export function DataConnectionForm({ + + {(props) => ( @@ -745,20 +728,10 @@ export function DataConnectionForm({ {mode === "edit" && ( - The proxy presents this OIDC subject when assuming the role. - In the role's trust policy, add a{" "} - StringLike condition on{" "} - data.source.coop:sub matching - it, alongside{" "} - - data.source.coop:aud = sts.amazonaws.com - - . - - } + label={t("trustSubjectLabel")} + help={t.rich("trustSubjectHelp", { + b: (chunks) => {chunks}, + })} group > @@ -773,10 +746,10 @@ export function DataConnectionForm({ )} {authType === DataConnectionAuthenticationType.AzureWorkloadIdentity && ( - + {(props) => ( @@ -795,8 +768,8 @@ export function DataConnectionForm({ {(props) => ( @@ -818,10 +791,10 @@ export function DataConnectionForm({ {authType === DataConnectionAuthenticationType.GcpWorkloadIdentity && ( - + {(props) => ( @@ -841,8 +814,8 @@ export function DataConnectionForm({ {(props) => ( @@ -866,11 +839,11 @@ export function DataConnectionForm({ - + {(props) => ( @@ -886,8 +859,11 @@ export function DataConnectionForm({ {(props) => ( @@ -906,16 +882,15 @@ export function DataConnectionForm({ prose and leaving the reader to run it in their head. */} - Where a product at{" "} + label={t("examplePrefixLabel")} + help={t.rich("examplePrefixHelp", { + path: "example-org/rainfall", + code: (chunks) => ( - example-org/rainfall - {" "} - would be stored. - - } + {chunks} + + ), + })} > - + @@ -995,10 +970,12 @@ export function DataConnectionForm({ > - {VISIBILITY_LABELS[visibility]} + {t(`visibility.${visibility}`)} + {/* Phrased from the connection's side: which products + it will carry. */} - {VISIBILITY_DESCRIPTIONS[visibility]} + {t(`visibilityDescription.${visibility}`)} @@ -1010,8 +987,8 @@ export function DataConnectionForm({ {/* Required Flag is a platform-only gate; hidden on owned connections. */} {!ownerAccountId && ( {(props) => ( @@ -1031,7 +1008,7 @@ export function DataConnectionForm({ style={{ width: "100%" }} /> - None + {tc("none")} {Object.values(AccountFlags).map((flag) => ( {flag} @@ -1048,7 +1025,7 @@ export function DataConnectionForm({ ; }) { + const t = useTranslations("DataConnectionsList"); if (!owner) { - return <>system; + return <>{t("systemOwner")}; } const account = ownerAccounts[owner]; @@ -93,9 +95,10 @@ export function DataConnectionsList({ editHref, ownerAccounts, }: DataConnectionsListProps) { + const t = useTranslations("DataConnectionsList"); if (connections.length === 0) { return ( - Create a data connection to get started. + {t("emptyHint")} ); } @@ -113,7 +116,7 @@ export function DataConnectionsList({ markers={ // The one state here worth marking. Read-only is deliberate, not a // fault, so it is marked where true and unmentioned where false. - conn.read_only && Read only + conn.read_only && {t("readOnly")} } meta={ <> @@ -132,8 +135,10 @@ export function DataConnectionsList({ aside={ {conn.allowed_visibilities.length === 0 - ? "permits nothing" - : conn.allowed_visibilities.join(", ")} + ? t("permitsNothing") + : conn.allowed_visibilities + .map((v) => t(`visibility.${v}`)) + .join(t("listSeparator"))} } /> diff --git a/src/components/features/data-connections/DeleteConnectionControl.tsx b/src/components/features/data-connections/DeleteConnectionControl.tsx index 3cba85dc6..97c6ca0df 100644 --- a/src/components/features/data-connections/DeleteConnectionControl.tsx +++ b/src/components/features/data-connections/DeleteConnectionControl.tsx @@ -1,6 +1,8 @@ import { Suspense } from "react"; import { Button, Flex, Text } from "@radix-ui/themes"; import { LockClosedIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; import { productsTable } from "@/lib/clients"; import { DeleteDataConnectionButton } from "./DeleteDataConnectionButton"; @@ -24,11 +26,12 @@ export function DeleteConnectionControl({ }: { connectionId: string; }) { + const t = useTranslations("DeleteDataConnectionButton"); return ( - Delete connection + {t("trigger")} } > @@ -71,16 +74,13 @@ export function DeleteConnectionNote({ async function DeleteNoteInner({ connectionId }: { connectionId: string }) { const products = await productsTable.listProductsByConnectionId(connectionId); if (products.length === 0) return null; + const t = await getTranslations("DeleteConnectionNote"); return ( - Blocked:{" "} - {products.length === 1 - ? "1 product still uses it" - : `${products.length} products still use it`} - . Remove it from each first. + {t("blocked", { count: products.length })} ); diff --git a/src/components/features/data-connections/DeleteDataConnectionButton.tsx b/src/components/features/data-connections/DeleteDataConnectionButton.tsx index 45b35d5ee..f1d1e10b2 100644 --- a/src/components/features/data-connections/DeleteDataConnectionButton.tsx +++ b/src/components/features/data-connections/DeleteDataConnectionButton.tsx @@ -4,6 +4,7 @@ import React, { useActionState } from "react"; import { Button, Text, Flex, AlertDialog } from "@radix-ui/themes"; import Form from "next/form"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import { deleteDataConnection } from "@/lib/actions/data-connections"; interface DeleteDataConnectionButtonProps { @@ -19,6 +20,8 @@ export function DeleteDataConnectionButton({ }: DeleteDataConnectionButtonProps) { const inUse = productsInUse > 0; const router = useRouter(); + const t = useTranslations("DeleteDataConnectionButton"); + const tc = useTranslations("Common"); const [state, formAction, pending] = useActionState(deleteDataConnection, { message: "", data: new FormData(), @@ -40,19 +43,18 @@ export function DeleteDataConnectionButton({ - Delete this connection? + {t("confirmTitle")} - This removes the connection record and its stored credentials. The - bucket and its objects are not touched. It cannot be undone. + {t("confirmDescription")}
    @@ -72,7 +74,7 @@ export function DeleteDataConnectionButton({ disabled={pending || inUse} loading={pending} > - Delete + {tc("delete")}
    diff --git a/src/components/features/data-connections/ProductMirrorsManager.tsx b/src/components/features/data-connections/ProductMirrorsManager.tsx index 1d31214ab..ee6f034fe 100644 --- a/src/components/features/data-connections/ProductMirrorsManager.tsx +++ b/src/components/features/data-connections/ProductMirrorsManager.tsx @@ -20,6 +20,7 @@ import { } from "@radix-ui/react-icons"; import Form from "next/form"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { Product } from "@/types"; import { Field, FormTitle, SectionHeader } from "@/components/core"; import { @@ -101,6 +102,8 @@ export function ProductMirrorsManager({ connectionInfo, editablePrefixConnectionIds, }: ProductMirrorsManagerProps) { + const t = useTranslations("ProductMirrorsManager"); + const tc = useTranslations("Common"); const ownedConnections = new Set(ownedConnectionIds); const editablePrefixConnections = new Set(editablePrefixConnectionIds); const [addState, addAction, addPending] = useActionState( @@ -185,8 +188,8 @@ export function ProductMirrorsManager({ return ( {!canManageMirrors && ( @@ -195,8 +198,10 @@ export function ProductMirrorsManager({ - Only owners and maintainers of {product.account_id} can - change this product's data connections. + {t.rich("readOnlyNotice", { + account: product.account_id, + code: (chunks) => {chunks}, + })} )} @@ -208,8 +213,7 @@ export function ProductMirrorsManager({ - The data proxy caches these settings. Changes here can take up to - five minutes to take effect. + {t("cacheNotice")} )} @@ -217,8 +221,8 @@ export function ProductMirrorsManager({ {mirrors.length === 0 ? ( {canManageMirrors - ? "Add a data connection to this product." - : "No data connections have been configured for this product."} + ? t("emptyManage") + : t("emptyReadOnly")} ) : ( @@ -246,7 +250,7 @@ export function ProductMirrorsManager({ } markers={ mirror.is_primary && ( - Primary + {t("primary")} ) } meta={info && `${info.provider} · ${info.bucket}`} @@ -258,7 +262,9 @@ export function ProductMirrorsManager({ size="1" variant="soft" color="gray" - aria-label={`Actions for ${info?.name ?? mirror.connection_id}`} + aria-label={t("actionsFor", { + name: info?.name ?? mirror.connection_id, + })} disabled={removePending || primaryPending} > @@ -271,7 +277,7 @@ export function ProductMirrorsManager({ dispatchForMirror(primaryAction, key, "primary") } > - Make primary + {t("makePrimary")} )} {canOpenConnection && ( @@ -293,7 +299,7 @@ export function ProductMirrorsManager({ ) } > - Edit connection + {t("editConnection")} )} @@ -304,7 +310,7 @@ export function ProductMirrorsManager({ color="red" onSelect={() => setPendingRemoval(key)} > - Remove from product… + {t("removeFromProduct")} )} @@ -328,8 +334,8 @@ export function ProductMirrorsManager({ /> {(controlProps) => ( @@ -338,7 +344,7 @@ export function ProductMirrorsManager({ name="prefix" size="1" defaultValue={mirror.prefix} - placeholder="(connection root)" + placeholder={t("connectionRoot")} style={{ flex: 1, fontFamily: "var(--code-font-family)", @@ -352,16 +358,16 @@ export function ProductMirrorsManager({ disabled={prefixPending} loading={prefixPending} > - Save + {tc("save")} )} ) : ( - + - {mirror.prefix || "(connection root)"} + {mirror.prefix || t("connectionRoot")} )} @@ -377,12 +383,12 @@ export function ProductMirrorsManager({ {canManageMirrors && ( - + {unusedConnections.length === 0 ? ( {availableConnections.length === 0 - ? "No other connections are available to this account." - : "Every connection available to this account is already attached."} + ? t("noneAvailable") + : t("allAttached")} ) : (
    @@ -399,7 +405,7 @@ export function ProductMirrorsManager({ @@ -420,7 +426,7 @@ export function ProductMirrorsManager({ disabled={addPending} loading={addPending} > - Add + {t("add")}
    @@ -446,16 +452,14 @@ export function ProductMirrorsManager({ onOpenChange={(open) => !open && setPendingRemoval(null)} > - Remove {removalName}? + {t("removeConfirmTitle", { name: removalName })} - This product will no longer be served from that storage. The objects - themselves are not deleted, and you can attach the connection again - later. + {t("removeConfirmDescription")} diff --git a/src/components/features/globe/LiveGlobe.tsx b/src/components/features/globe/LiveGlobe.tsx index f3e176456..36ea1960a 100644 --- a/src/components/features/globe/LiveGlobe.tsx +++ b/src/components/features/globe/LiveGlobe.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Theme, Tooltip } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { CONFIG } from "@/lib/config"; import Globe, { GlobeMethods } from "react-globe.gl"; import { @@ -96,6 +97,7 @@ export function LiveGlobe({ }: LiveGlobeProps) { const globeRef = useRef(undefined); const pointsRef = useRef([]); + const t = useTranslations("LiveGlobe"); const [globeReady, setGlobeReady] = useState(false); const [sceneReady, setSceneReady] = useState(false); const globeReadyRef = useRef(false); @@ -543,7 +545,7 @@ export function LiveGlobe({ ref={wrapperRef} className={`${styles.container} ${sceneReady ? styles.ready : styles.loading}`} role="img" - aria-label="Interactive 3D globe showing live data request locations" + aria-label={t("aria")} > {name} diff --git a/src/components/features/keyboard/ShortcutHelp.tsx b/src/components/features/keyboard/ShortcutHelp.tsx index 29c58b40a..c169142ae 100644 --- a/src/components/features/keyboard/ShortcutHelp.tsx +++ b/src/components/features/keyboard/ShortcutHelp.tsx @@ -1,6 +1,7 @@ 'use client'; import { Dialog, Box, Text, Table } from '@radix-ui/themes'; +import { useTranslations } from 'next-intl'; interface ShortcutHelpProps { open: boolean; @@ -8,41 +9,64 @@ interface ShortcutHelpProps { context: 'product-list' | 'object-browser' | 'object-details'; } +type ShortcutAction = + | 'toggleHelp' + | 'goHome' + | 'navigateUp' + | 'navigateDown' + | 'openSelectedProduct' + | 'copySelectedProductUrl' + | 'clearSelection' + | 'openSelectedItem' + | 'copySelectedItemUrl' + | 'goUpOneLevel' + | 'navigateUpProperties' + | 'navigateDownProperties' + | 'copySelectedPropertyValue' + | 'returnToDirectoryView'; + interface ShortcutItem { key: string; - action: string; + action: ShortcutAction; } const GLOBAL_SHORTCUTS: ShortcutItem[] = [ - { key: '?', action: 'Show/hide this help dialog' }, - { key: 'g h', action: 'Go to homepage' }, + { key: '?', action: 'toggleHelp' }, + { key: 'g h', action: 'goHome' }, ]; const PRODUCT_LIST_SHORTCUTS: ShortcutItem[] = [ - { key: '↑ / k', action: 'Navigate up' }, - { key: '↓ / j', action: 'Navigate down' }, - { key: 'Enter / o', action: 'Open selected product' }, - { key: 'c', action: 'Copy URL of selected product' }, - { key: 'Shift+Escape', action: 'Clear selection' }, + { key: '↑ / k', action: 'navigateUp' }, + { key: '↓ / j', action: 'navigateDown' }, + { key: 'Enter / o', action: 'openSelectedProduct' }, + { key: 'c', action: 'copySelectedProductUrl' }, + { key: 'Shift+Escape', action: 'clearSelection' }, ]; const OBJECT_BROWSER_SHORTCUTS: ShortcutItem[] = [ - { key: '↑ / k', action: 'Navigate up' }, - { key: '↓ / j', action: 'Navigate down' }, - { key: 'Enter / o', action: 'Open selected item' }, - { key: 'c', action: 'Copy URL of selected item' }, - { key: '~', action: 'Go up one level' }, - { key: 'Shift+Escape', action: 'Clear selection' }, + { key: '↑ / k', action: 'navigateUp' }, + { key: '↓ / j', action: 'navigateDown' }, + { key: 'Enter / o', action: 'openSelectedItem' }, + { key: 'c', action: 'copySelectedItemUrl' }, + { key: '~', action: 'goUpOneLevel' }, + { key: 'Shift+Escape', action: 'clearSelection' }, ]; +const DESCRIPTION_KEYS = { + 'product-list': 'descriptionProductList', + 'object-browser': 'descriptionObjectBrowser', + 'object-details': 'descriptionObjectDetails', +} as const; + const OBJECT_DETAILS_SHORTCUTS: ShortcutItem[] = [ - { key: '↑ / k', action: 'Navigate up through properties' }, - { key: '↓ / j', action: 'Navigate down through properties' }, - { key: 'c', action: 'Copy selected property value' }, - { key: '~', action: 'Return to directory view' }, + { key: '↑ / k', action: 'navigateUpProperties' }, + { key: '↓ / j', action: 'navigateDownProperties' }, + { key: 'c', action: 'copySelectedPropertyValue' }, + { key: '~', action: 'returnToDirectoryView' }, ]; export function ShortcutHelp({ open, onOpenChange, context }: ShortcutHelpProps) { + const t = useTranslations('ShortcutHelp'); const contextShortcuts = (() => { switch (context) { case 'product-list': @@ -56,12 +80,12 @@ export function ShortcutHelp({ open, onOpenChange, context }: ShortcutHelpProps) } })(); - const description = `Keyboard shortcuts for ${context.replace('-', ' ')}. Use these shortcuts to navigate and interact with the interface.`; + const description = t(DESCRIPTION_KEYS[context]); return ( - Keyboard Shortcuts + {t('title')} {description} @@ -69,28 +93,28 @@ export function ShortcutHelp({ open, onOpenChange, context }: ShortcutHelpProps) - Key - Action + {t('keyHeader')} + {t('actionHeader')} {GLOBAL_SHORTCUTS.map((shortcut) => ( {shortcut.key} - {shortcut.action} + {t(shortcut.action)} ))} {contextShortcuts.map((shortcut) => ( {shortcut.key} - {shortcut.action} + {t(shortcut.action)} ))}
    - Press Escape to close + {t('pressEscapeToClose')} diff --git a/src/components/features/markdown/HeadingWithPermalink.tsx b/src/components/features/markdown/HeadingWithPermalink.tsx index 7a46382f2..8746a2cc0 100644 --- a/src/components/features/markdown/HeadingWithPermalink.tsx +++ b/src/components/features/markdown/HeadingWithPermalink.tsx @@ -1,6 +1,7 @@ import { ReactNode } from "react"; import { Heading } from "@radix-ui/themes"; import { Link2Icon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; interface HeadingWithPermalinkProps { id: string; @@ -15,10 +16,11 @@ export function HeadingWithPermalink({ children, mb, }: HeadingWithPermalinkProps) { + const t = useTranslations("HeadingWithPermalink"); return ( {children} - + diff --git a/src/components/features/memberships/PendingInvitationBanner.tsx b/src/components/features/memberships/PendingInvitationBanner.tsx index ee723039d..8d458e37b 100644 --- a/src/components/features/memberships/PendingInvitationBanner.tsx +++ b/src/components/features/memberships/PendingInvitationBanner.tsx @@ -4,6 +4,7 @@ import { Membership, MembershipRole } from "@/types"; import { Callout, Button, Flex } from "@radix-ui/themes"; import { InfoCircledIcon } from "@radix-ui/react-icons"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { acceptInvitation, rejectInvitation } from "@/lib/actions/memberships"; interface PendingInvitationBannerProps { @@ -17,6 +18,7 @@ export function PendingInvitationBanner({ organizationName, productName, }: PendingInvitationBannerProps) { + const t = useTranslations("PendingInvitationBanner"); const [isProcessing, setIsProcessing] = useState(false); const [isHidden, setIsHidden] = useState(false); @@ -30,11 +32,11 @@ export function PendingInvitationBanner({ setIsHidden(true); window.location.reload(); // Reload to show updated UI } else { - alert(result.error || "Failed to accept invitation"); + alert(result.error || t("acceptFailed")); setIsProcessing(false); } } catch (error) { - alert("An error occurred while accepting the invitation"); + alert(t("acceptError")); setIsProcessing(false); } }; @@ -46,11 +48,11 @@ export function PendingInvitationBanner({ if (result.success) { setIsHidden(true); } else { - alert(result.error || "Failed to reject invitation"); + alert(result.error || t("rejectFailed")); setIsProcessing(false); } } catch (error) { - alert("An error occurred while rejecting the invitation"); + alert(t("rejectError")); setIsProcessing(false); } }; @@ -61,10 +63,10 @@ export function PendingInvitationBanner({ const roleName = ( { - owners: "Owner", - maintainers: "Maintainer", - read_data: "Data Reader", - write_data: "Data Writer", + owners: t("roleOwners"), + maintainers: t("roleMaintainers"), + read_data: t("roleReadData"), + write_data: t("roleWriteData"), } as Record )[invitation.role] || invitation.role; @@ -75,11 +77,12 @@ export function PendingInvitationBanner({ - You have been invited to join this {invitationType} as a - {["a", "e", "i", "o", "u"].includes(roleName.charAt(0).toLowerCase()) - ? "n " - : " "} - {roleName}. + {t.rich("message", { + invitationType, + role: invitation.role, + roleName, + strong: (chunks) => {chunks}, + })} diff --git a/src/components/features/metadata/AccountMetadata.tsx b/src/components/features/metadata/AccountMetadata.tsx index 0d7cc9be3..9d7dbf4ea 100644 --- a/src/components/features/metadata/AccountMetadata.tsx +++ b/src/components/features/metadata/AccountMetadata.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import type { Account } from "@/types"; import { CONFIG } from "@/lib"; import { getBaseUrl } from "@/lib/baseUrl"; @@ -12,15 +13,14 @@ export async function generateAccountMetadata({ account, }: AccountMetadataProps): Promise { const baseUrl = await getBaseUrl(); + const t = await getTranslations("AccountMetadata"); - const accountType = - account.type === AccountType.INDIVIDUAL ? "Individual" : "Organization"; - const title = `${account.name} · ${accountType} · Source Cooperative`; + const type = + account.type === AccountType.INDIVIDUAL ? "individual" : "organization"; + const title = t("title", { name: account.name, type }); const description = account.metadata_public.bio || - `${account.name} is ${ - account.type === AccountType.INDIVIDUAL ? "an" : "an" - } ${accountType.toLowerCase()} on Source Cooperative`; + t("description", { name: account.name, type }); const url = `${baseUrl}/${account.account_id}`; // Generate OG image URL diff --git a/src/components/features/metadata/NotFoundMetadata.tsx b/src/components/features/metadata/NotFoundMetadata.tsx index c6eb5ca7f..98856f76d 100644 --- a/src/components/features/metadata/NotFoundMetadata.tsx +++ b/src/components/features/metadata/NotFoundMetadata.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { CONFIG } from "@/lib"; import { getBaseUrl } from "@/lib/baseUrl"; @@ -9,10 +10,13 @@ interface NotFoundMetadataProps { } export async function generateNotFoundMetadata({ - title = "Page Not Found", - description = "The page you are looking for does not exist.", + title, + description, url, }: NotFoundMetadataProps = {}): Promise { + const t = await getTranslations("NotFoundMetadata"); + title ??= t("title"); + description ??= t("description"); const fullTitle = `${title} · Source Cooperative`; const baseUrl = await getBaseUrl(); const canonicalUrl = url ? `${baseUrl}${url}` : undefined; diff --git a/src/components/features/metadata/ProductMetadata.tsx b/src/components/features/metadata/ProductMetadata.tsx index 96e7d5f00..6a6979249 100644 --- a/src/components/features/metadata/ProductMetadata.tsx +++ b/src/components/features/metadata/ProductMetadata.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { ProductVisibility, type Product } from "@/types"; import { CONFIG } from "@/lib"; import { getBaseUrl } from "@/lib/baseUrl"; @@ -10,14 +11,15 @@ interface ProductMetadataProps { export async function generateProductMetadata({ product, }: ProductMetadataProps): Promise { + const t = await getTranslations("ProductMetadata"); // Handle case where account might be undefined - const accountName = product.account?.name || "Unknown Account"; + const accountName = product.account?.name || t("unknownAccount"); const accountId = product.account?.account_id || "unknown"; const baseUrl = await getBaseUrl(); const title = `${product.title} · ${accountName} · Source Cooperative`; - const description = product.description || `A data product by ${accountName}`; + const description = product.description || t("description", { account: accountName }); const url = `${baseUrl}/${accountId}/${product.product_id}`; // Generate OG image URL diff --git a/src/components/features/metadata/STACSection.tsx b/src/components/features/metadata/STACSection.tsx index 24752b15d..ece5be230 100644 --- a/src/components/features/metadata/STACSection.tsx +++ b/src/components/features/metadata/STACSection.tsx @@ -1,16 +1,21 @@ import { Box, Text, Link } from '@radix-ui/themes'; +import { useTranslations } from 'next-intl'; interface STACSectionProps { stacUrl?: string; } export function STACSection({ stacUrl }: STACSectionProps) { + const t = useTranslations('STACSection'); if (!stacUrl) return null; return ( - STAC Catalog: {stacUrl} + {t.rich('catalog', { + url: stacUrl, + link: (chunks) => {chunks}, + })} ); diff --git a/src/components/features/onboarding/OnboardingForm.tsx b/src/components/features/onboarding/OnboardingForm.tsx index 85c917119..5474a6c10 100644 --- a/src/components/features/onboarding/OnboardingForm.tsx +++ b/src/components/features/onboarding/OnboardingForm.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Box, Text } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { DynamicForm, FormField } from "@/components/core"; import { createAccount } from "@/lib/actions/account"; import { AccountType, Account } from "@/types"; @@ -11,17 +12,18 @@ import { EmailVerificationCallout } from "@/components/features/auth/EmailVerifi type OnboardingFormData = Pick; export function OnboardingForm({ identityId }: { identityId: string }) { + const t = useTranslations("OnboardingForm"); const [accountId, setAccountId] = useState(""); const validationState = useAccountIdValidation(accountId); const fields: FormField[] = [ { - label: "Username", + label: t("username"), name: "account_id", type: "text", required: true, - description: "This will be your profile URL: source.coop/[username]", - placeholder: "Choose a username", + description: t("usernameDescription"), + placeholder: t("usernamePlaceholder"), controlled: true, value: accountId, onValueChange: (value: string) => { @@ -31,11 +33,11 @@ export function OnboardingForm({ identityId }: { identityId: string }) { isValid: !!validationState.isValid, message: validationState.isLoading ? ( - Checking username availability... + {t("checking")} ) : validationState.isValid === true ? ( - ✓ Username is available + {t("available")} ) : validationState.isValid === false && validationState.error ? ( @@ -44,12 +46,12 @@ export function OnboardingForm({ identityId }: { identityId: string }) { ) : null, }, { - label: "Full Name", + label: t("fullName"), name: "name", type: "text", required: true, - description: "This is the name that will be displayed on your profile", - placeholder: "Your Name", + description: t("fullNameDescription"), + placeholder: t("fullNamePlaceholder"), }, ]; @@ -59,7 +61,7 @@ export function OnboardingForm({ identityId }: { identityId: string }) { fields={fields} action={createAccount} - submitButtonText="Complete profile" + submitButtonText={t("submit")} hiddenFields={{ type: AccountType.INDIVIDUAL, identity_id: identityId, diff --git a/src/components/features/organizations/OrganizationCreationForm.tsx b/src/components/features/organizations/OrganizationCreationForm.tsx index 73bde8270..28359a1eb 100644 --- a/src/components/features/organizations/OrganizationCreationForm.tsx +++ b/src/components/features/organizations/OrganizationCreationForm.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Text } from "@radix-ui/themes"; +import { useTranslations } from "next-intl"; import { DynamicForm, FormField } from "@/components/core"; import { createAccount } from "@/lib/actions/account"; import { AccountType } from "@/types"; @@ -22,36 +23,37 @@ interface OrganizationFormData { export function OrganizationCreationForm({ ownerAccountId, }: OrganizationCreationFormProps) { + const t = useTranslations("OrganizationCreationForm"); const [accountId, setAccountId] = useState(""); const validationState = useAccountIdValidation(accountId); const fields: FormField[] = [ { - label: "Organization Name", + label: t("name"), name: "name", type: "text", required: true, - description: "The name of your organization", - placeholder: "Enter organization name", + description: t("nameDescription"), + placeholder: t("namePlaceholder"), }, { - label: "Account ID", + label: t("accountId"), name: "account_id", type: "text", required: true, - description: "The account ID of your organization", - placeholder: "Enter account ID", + description: t("accountIdDescription"), + placeholder: t("accountIdPlaceholder"), controlled: true, value: accountId, onValueChange: setAccountId, isValid: !!validationState.isValid, // not checked (null) -> invalid message: validationState.isLoading ? ( - Checking account ID availability... + {t("checking")} ) : validationState.isValid === true ? ( - ✓ Available + {t("available")} ) : validationState.isValid === false && validationState.error ? ( @@ -60,25 +62,25 @@ export function OrganizationCreationForm({ ) : null, }, { - label: "Description", + label: t("description"), name: "description", type: "textarea", required: true, - description: "A brief description of your organization", - placeholder: "Describe your organization", + description: t("descriptionDescription"), + placeholder: t("descriptionPlaceholder"), }, { - label: "Website", + label: t("website"), name: "website", type: "url", - description: "Your organization's website (optional)", + description: t("websiteDescription"), placeholder: "https://example.com", }, { - label: "Email", + label: t("email"), name: "email", type: "email", - description: "Contact email for your organization (optional)", + description: t("emailDescription"), placeholder: "contact@example.com", }, ]; @@ -87,7 +89,7 @@ export function OrganizationCreationForm({ fields={fields} action={createAccount} - submitButtonText="Create" + submitButtonText={t("submit")} hiddenFields={{ owner_account_id: ownerAccountId, type: AccountType.ORGANIZATION, diff --git a/src/components/features/products/ChecksumVerifier.tsx b/src/components/features/products/ChecksumVerifier.tsx index 1be74ec87..e545a589e 100644 --- a/src/components/features/products/ChecksumVerifier.tsx +++ b/src/components/features/products/ChecksumVerifier.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { Text, Flex, Button, Box } from '@radix-ui/themes'; import { CheckCircledIcon, CrossCircledIcon, UpdateIcon } from '@radix-ui/react-icons'; +import { useTranslations } from 'next-intl'; interface ChecksumVerifierProps { objectUrl: string; @@ -17,6 +18,7 @@ export function ChecksumVerifier({ }: ChecksumVerifierProps) { const [status, setStatus] = useState<'idle' | 'checking' | 'match' | 'mismatch' | 'error'>('idle'); const [error, setError] = useState(null); + const t = useTranslations('ChecksumVerifier'); async function verifyChecksum() { try { @@ -33,14 +35,14 @@ export function ChecksumVerifier({ const headerHash = response.headers.get(algorithm === 'SHA-256' ? 'x-amz-checksum-sha256' : 'x-amz-checksum-sha1'); if (!headerHash) { - setError('Checksum not available in response headers'); + setError(t('notAvailable')); setStatus('error'); return; } setStatus(headerHash.toLowerCase() === expectedHash.toLowerCase() ? 'match' : 'mismatch'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to verify checksum'); + setError(err instanceof Error ? err.message : t('failed')); setStatus('error'); } } @@ -52,12 +54,12 @@ export function ChecksumVerifier({ onClick={verifyChecksum} disabled={status === 'checking'} > - Verify {algorithm} + {t('verify', { algorithm })} {status === 'checking' && ( - Verifying... + {t('verifying')} )} {status === 'match' && ( @@ -65,7 +67,7 @@ export function ChecksumVerifier({ - Checksum verified + {t('verified')} )} {status === 'mismatch' && ( @@ -73,7 +75,7 @@ export function ChecksumVerifier({ - Checksum mismatch + {t('mismatch')} )} {status === 'error' && ( diff --git a/src/components/features/products/DeleteProductModal.tsx b/src/components/features/products/DeleteProductModal.tsx index 0a03bff5e..6f6ffeda8 100644 --- a/src/components/features/products/DeleteProductModal.tsx +++ b/src/components/features/products/DeleteProductModal.tsx @@ -19,6 +19,7 @@ import { TrashIcon, ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { deleteProduct } from "@/lib/actions/products"; import { useRouter } from "next/navigation"; import { accountUrl } from "@/lib/urls"; +import { useTranslations } from "next-intl"; interface DeleteProductModalProps { accountId: string; @@ -48,6 +49,8 @@ export function DeleteProductModal({ const [error, setError] = useState(null); const [isPending, startTransition] = useTransition(); const router = useRouter(); + const t = useTranslations("DeleteProductModal"); + const tCommon = useTranslations("Common"); const expectedText = `${accountId}/${productId}`; const isConfirmed = confirmText === expectedText; @@ -74,7 +77,7 @@ export function DeleteProductModal({ if (result.success) { router.push(accountUrl(accountId)); } else { - setError(result.error ?? "Failed to delete product"); + setError(result.error ?? t("deleteFailed")); } }); }; @@ -84,19 +87,19 @@ export function DeleteProductModal({ - Delete Product + {t("deleteProduct")} {showKeepOption && ( @@ -127,7 +130,10 @@ export function DeleteProductModal({ - To confirm, type {expectedText} below: + {t.rich("confirmPrompt", { + expected: expectedText, + code: (chunks) => {chunks}, + })} - Deleting this product will: + {t("consequencesHeading")}
    • - Remove the product record and its page + {t("consequenceRecord")}
    • - Remove all of its memberships + {t("consequenceMemberships")}
    • {keepData ? ( - Keep the underlying data in the connection + {t("consequenceKeepData")} ) : ( - Delete all underlying data in the connection + {t("consequenceDeleteData")} )}
    - This cannot be undone. + {t("cannotBeUndone")}
    @@ -195,7 +201,7 @@ export function DeleteProductModal({
    diff --git a/src/components/features/products/Pagination.tsx b/src/components/features/products/Pagination.tsx index 67f7e134a..3260b3272 100644 --- a/src/components/features/products/Pagination.tsx +++ b/src/components/features/products/Pagination.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { Button, Flex } from "@radix-ui/themes"; import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; +import { useTranslations } from "next-intl"; import type { PaginationProps } from "./ProductsList"; export function Pagination({ @@ -15,6 +16,7 @@ export function Pagination({ }: PaginationProps) { const pathname = usePathname(); const searchParams = useSearchParams(); + const t = useTranslations("Common"); if (!hasNextPage && !hasPreviousPage) return null; @@ -48,23 +50,23 @@ export function Pagination({ {previousUrl ? ( ) : ( )} {nextUrl ? ( ) : ( )} diff --git a/src/components/features/products/ProductCreationForm.tsx b/src/components/features/products/ProductCreationForm.tsx index 2c81095c6..75d480155 100644 --- a/src/components/features/products/ProductCreationForm.tsx +++ b/src/components/features/products/ProductCreationForm.tsx @@ -7,20 +7,16 @@ import { Account, DataConnection } from "@/types"; import { Product, ProductVisibility } from "@/types/product"; import { useProductIdValidation } from "@/hooks/useIdValidation"; import { createProduct, updateProduct } from "@/lib/actions/products"; +import { useFormatter, useTranslations } from "next-intl"; -const VISIBILITY_LABELS: Record = { - [ProductVisibility.Public]: "Public", - [ProductVisibility.Unlisted]: "Unlisted", - [ProductVisibility.Restricted]: "Restricted", -}; +type Translator = ReturnType>; +type Formatter = ReturnType; -const VISIBILITY_DESCRIPTIONS: Record = { - [ProductVisibility.Public]: - "Anyone can find and download it. Appears in search and the product feed.", - [ProductVisibility.Unlisted]: - "Anyone with the link can download it. Hidden from search and the feed.", - [ProductVisibility.Restricted]: "Members of this product only.", -}; +const VISIBILITY_KEYS = { + [ProductVisibility.Public]: "public", + [ProductVisibility.Unlisted]: "unlisted", + [ProductVisibility.Restricted]: "restricted", +} as const satisfies Record; // Fallback when no data connection is selected (e.g. legacy products in edit // mode whose connection can no longer be resolved). @@ -63,14 +59,26 @@ function pickDefaultConnection( } // "AWS Open Data (us-west-2) · Public, Unlisted · Read Only" -function describeConnection(connection: DataConnection): string { +function describeConnection( + connection: DataConnection, + t: Translator, + format: Formatter +): string { const visibilities = - connection.allowed_visibilities.map((v) => VISIBILITY_LABELS[v]).join(", ") || - "no visibilities"; + format.list( + connection.allowed_visibilities.map((v) => + t(`visibility.${VISIBILITY_KEYS[v]}.label`) + ), + // Narrow conjunction is the bare comma list ("Public, Unlisted") in English. + { type: "conjunction", style: "narrow" } + ) || t("noVisibilities"); const region = regionOf(connection); const location = region ? ` (${region})` : ""; - const readOnly = connection.read_only ? " · Read Only" : ""; - return `${connection.name}${location} · ${visibilities}${readOnly}`; + return t(connection.read_only ? "connectionOptionReadOnly" : "connectionOption", { + name: connection.name, + location, + visibilities, + }); } interface ProductCreationFormProps { @@ -89,6 +97,9 @@ export function ProductCreationForm({ defaultOwnerId, }: ProductCreationFormProps) { const isEditMode = mode === "edit" && product; + const t = useTranslations("ProductCreationForm"); + const tCommon = useTranslations("Common"); + const format = useFormatter(); // In create mode, start on the preselected owner when given, else the first. const initialOwnerId = isEditMode @@ -185,25 +196,25 @@ export function ProductCreationForm({ const fields: FormField[] = [ { - label: "Product Title", + label: t("titleLabel"), name: "title", type: "text", required: true, - section: "Description", - description: "The name of your product", - placeholder: "Enter product name", + section: t("sectionDescription"), + description: t("titleDescription"), + placeholder: t("titlePlaceholder"), }, // Only show account selection and product ID validation in create mode ...(isEditMode ? [] : ([ { - label: "Owner Account", + label: t("ownerLabel"), name: "account_id", type: "select", required: true, - section: "Description", - description: "The account that owns the product", + section: t("sectionDescription"), + description: t("ownerDescription"), options: potentialOwnerAccounts.map((account) => ({ value: account.account_id, label: account.name, @@ -213,14 +224,14 @@ export function ProductCreationForm({ onValueChange: handleAccountChange, }, { - label: "Product ID", + label: t("productIdLabel"), name: "product_id", type: "text", required: true, - section: "Description", + section: t("sectionDescription"), mono: true, - description: "The ID of your product", - placeholder: "Enter product ID", + description: t("productIdDescription"), + placeholder: t("productIdPlaceholder"), controlled: true, value: productId, onValueChange: setProductId, @@ -229,13 +240,18 @@ export function ProductCreationForm({ - Checking availability of{" "} - {`${accountId}/${productId}`} + {t.rich("checkingAvailability", { + id: `${accountId}/${productId}`, + code: (chunks) => {chunks}, + })} ) : validationState.isValid === true ? ( - ✓ Available: {`${accountId}/${productId}`} + {t.rich("available", { + id: `${accountId}/${productId}`, + code: (chunks) => {chunks}, + })} ) : validationState.isValid === false && validationState.error ? ( @@ -245,13 +261,13 @@ export function ProductCreationForm({ }, ] as FormField[])), { - label: "Description", + label: t("descriptionLabel"), name: "description", type: "textarea", required: false, - section: "Description", - description: "A brief description of your product", - placeholder: "Describe your product", + section: t("sectionDescription"), + description: t("descriptionDescription"), + placeholder: t("descriptionPlaceholder"), }, // Data connection selector (create mode only). Drives the region and the // visibility options available below. @@ -259,13 +275,12 @@ export function ProductCreationForm({ ? [] : ([ { - label: "Data Connection", + label: t("connectionLabel"), name: "data_connection_id" as keyof Product, type: "select", required: true, - section: "Storage", - description: - "Where this product's data is stored. Determines the available region and visibility options. If you're unsure, choose a us-west-2 connection.", + section: t("sectionStorage"), + description: t("connectionDescription"), options: [...availableConnections] .sort( (a, b) => @@ -274,11 +289,11 @@ export function ProductCreationForm({ ) .map((connection) => ({ value: connection.data_connection_id, - label: describeConnection(connection), + label: describeConnection(connection, t, format), })), placeholder: availableConnections.length === 0 - ? "No data connections available" + ? t("noConnections") : undefined, readOnly: availableConnections.length === 0, controlled: true, @@ -287,21 +302,21 @@ export function ProductCreationForm({ }, ] as FormField[])), { - label: "Visibility", + label: t("visibilityLabel"), name: "visibility", type: "radio-cards", required: true, - section: "Access", + section: t("sectionAccess"), description: connectionMissing - ? "This product's data connection could not be found, so its visibility can't be changed." - : "Who can reach this product. The options depend on its data connection.", + ? t("visibilityConnectionMissing") + : t("visibilityDescription"), // Every visibility is listed; the connection decides which are selectable. options: ALL_VISIBILITIES.map((value) => { const permitted = allowedVisibilities.includes(value); return { value, - label: VISIBILITY_LABELS[value], - description: VISIBILITY_DESCRIPTIONS[value], + label: t(`visibility.${VISIBILITY_KEYS[value]}.label`), + description: t(`visibility.${VISIBILITY_KEYS[value]}.description`), // The current value stays selectable even when the connection no // longer permits it (legacy drift), so an edit of some other field // isn't blocked by a visibility the user didn't choose today. @@ -320,15 +335,14 @@ export function ProductCreationForm({ ...(isEditMode ? ([ { - label: "Status", + label: t("statusLabel"), name: "disabled" as keyof Product, type: "switch", - section: "Access", - switchLabel: disabled ? "Deactivated" : "Active", + section: t("sectionAccess"), + switchLabel: disabled ? t("statusDeactivated") : t("statusActive"), invert: true, dangerWhenOff: true, - description: - "Deactivating hides the product from source.coop and blocks the data.source.coop API. The data is kept, but only a Source Cooperative administrator can reactivate it.", + description: t("statusDescription"), controlled: true, value: String(disabled), onValueChange: (value) => setDisabled(value === "true"), @@ -341,7 +355,7 @@ export function ProductCreationForm({ - {message ?? - "Your access to this private product's data couldn't be confirmed just now."} + {message ?? t("accessUnconfirmed")} {details && ( @@ -47,7 +48,7 @@ export function ProductDataUnavailable({ color: "var(--gray-11)", }} > - Error details (visible to maintainers only) + {t("errorDetails")}
             
           
         
    diff --git a/src/components/features/products/ProductListItem.tsx b/src/components/features/products/ProductListItem.tsx
    index 0a3e0785c..9c5d132cd 100644
    --- a/src/components/features/products/ProductListItem.tsx
    +++ b/src/components/features/products/ProductListItem.tsx
    @@ -10,6 +10,7 @@ import { productUrl } from "@/lib/urls";
     import { DisplayNameLink } from "@/components/core";
     import ReactMarkdown, { type Components } from "react-markdown";
     import remarkGfm from "remark-gfm";
    +import { useTranslations } from "next-intl";
     
     interface ProductListItemProps {
       product: Product;
    @@ -30,12 +31,13 @@ const DESCRIPTION_COMPONENTS: Components = {
     };
     
     const VISIBILITY_CONFIG = {
    -  public: { color: "green" as const, label: "Public" },
    -  unlisted: { color: "yellow" as const, label: "Unlisted" },
    -  restricted: { color: "red" as const, label: "Restricted" },
    +  public: { color: "green" as const, labelKey: "public", ariaKey: "publicProduct" },
    +  unlisted: { color: "yellow" as const, labelKey: "unlisted", ariaKey: "unlistedProduct" },
    +  restricted: { color: "red" as const, labelKey: "restricted", ariaKey: "restrictedProduct" },
     } as const;
     
     export function ProductListItem({ product, isSelected }: ProductListItemProps) {
    +  const t = useTranslations("ProductListItem");
       const visibility =
         VISIBILITY_CONFIG[product.visibility] || VISIBILITY_CONFIG.restricted;
     
    @@ -77,24 +79,28 @@ export function ProductListItem({ product, isSelected }: ProductListItemProps) {
               {product.account?.name && (
                 <>
                   
    -                Provided by 
    +                {t.rich("providedBy", {
    +                  account: () => ,
    +                })}
                   
                   {" • "}
                 
               )}
               
    -            Published on 
    +            {t.rich("publishedOn", {
    +              date: () => ,
    +            })}
               
               
    -            {visibility.label}
    +            {t(visibility.labelKey)}
               
               {product.disabled && (
    -            
    -              Deactivated
    +            
    +              {t("deactivated")}
                 
               )}
             
    diff --git a/src/components/features/products/ProductMetaCard.tsx b/src/components/features/products/ProductMetaCard.tsx
    index c9b5a98e3..6b444b3be 100644
    --- a/src/components/features/products/ProductMetaCard.tsx
    +++ b/src/components/features/products/ProductMetaCard.tsx
    @@ -2,15 +2,17 @@ import { Card } from '@radix-ui/themes';
     import type { Product } from "@/types";
     import { SectionHeader } from '@/components/core';
     import { ProductMetaContent } from './ProductMetaContent';
    +import { useTranslations } from 'next-intl';
     
     interface ProductMetaCardProps {
       product: Product;
     }
     
     export function ProductMetaCard({ product }: ProductMetaCardProps) {
    +  const t = useTranslations('ProductMetaCard');
       return (
         
    -      
    +      
             
           
         
    diff --git a/src/components/features/products/ProductMetaContent.tsx b/src/components/features/products/ProductMetaContent.tsx
    index 3b8b2aef0..b024b4a8f 100644
    --- a/src/components/features/products/ProductMetaContent.tsx
    +++ b/src/components/features/products/ProductMetaContent.tsx
    @@ -2,16 +2,18 @@ import { DataList, Badge } from "@radix-ui/themes";
     import type { Product } from "@/types";
     import { AvatarLinkCompact } from "@/components/core";
     import { DateText } from "@/components/display";
    +import { useTranslations } from "next-intl";
     
     interface ProductMetaContentProps {
       product: Product;
     }
     
     export function ProductMetaContent({ product }: ProductMetaContentProps) {
    +  const t = useTranslations("ProductMetaContent");
       return (
         
           
    -        Visibility
    +        {t("visibility")}
             
               
                 {product.visibility === "public"
    -              ? "Public"
    +              ? t("public")
                   : product.visibility === "unlisted"
    -              ? "Unlisted"
    -              : "Restricted"}
    +              ? t("unlisted")
    +              : t("restricted")}
               
             
           
     
           
    -        Owner
    +        {t("owner")}
             
               
             
           
     
           
    -        Created
    +        {t("created")}
             
               
             
           
     
           
    -        Last Updated
    +        {t("lastUpdated")}
             
               
             
    diff --git a/src/components/features/products/ProductSearchResult.tsx b/src/components/features/products/ProductSearchResult.tsx
    index 991db576e..cf32ce65e 100644
    --- a/src/components/features/products/ProductSearchResult.tsx
    +++ b/src/components/features/products/ProductSearchResult.tsx
    @@ -5,6 +5,7 @@ import DOMPurify from 'isomorphic-dompurify';
     import type { Product } from "@/types";
     import { DateText } from '@/components/display';
     import { productUrl } from "@/lib/urls";
    +import { useTranslations } from 'next-intl';
     
     interface ProductSearchResultProps {
       product: Product;
    @@ -15,6 +16,7 @@ interface ProductSearchResultProps {
     }
     
     export function ProductSearchResult({ product, highlight }: ProductSearchResultProps) {
    +  const t = useTranslations('ProductSearchResult');
       const sanitizedTitle = highlight?.title ? DOMPurify.sanitize(highlight.title) : product.title;
       const sanitizedDescription = highlight?.description ? 
         DOMPurify.sanitize(highlight.description) : 
    @@ -37,13 +39,15 @@ export function ProductSearchResult({ product, highlight }: ProductSearchResultP
               )}
               
                 
    -              Updated 
    +              {t.rich('updated', {
    +                date: () => ,
    +              })}
                 
                 
                   {product.account?.name}
                 
                 
    -              {product.visibility === "public" ? "Public" : "Private"}
    +              {product.visibility === "public" ? t('public') : t('private')}
                 
               
             
    diff --git a/src/components/features/products/ProductsFilters.tsx b/src/components/features/products/ProductsFilters.tsx
    index a035a1ca2..680abb689 100644
    --- a/src/components/features/products/ProductsFilters.tsx
    +++ b/src/components/features/products/ProductsFilters.tsx
    @@ -4,6 +4,7 @@ import { useTransition, useState, useEffect } from "react";
     import { useRouter, usePathname, useSearchParams } from "next/navigation";
     import { TextField, Button, Flex } from "@radix-ui/themes";
     import { Cross2Icon, StarFilledIcon } from "@radix-ui/react-icons";
    +import { useTranslations } from "next-intl";
     import { useDebounce } from "@/hooks/useDebounce";
     
     export function ProductsFilters() {
    @@ -11,6 +12,7 @@ export function ProductsFilters() {
       const pathname = usePathname();
       const searchParams = useSearchParams();
       const [isPending, startTransition] = useTransition();
    +  const t = useTranslations("ProductsFilters");
     
       // Read current values from URL
       const currentSearch = searchParams.get("search") || "";
    @@ -93,7 +95,7 @@ export function ProductsFilters() {
            setSearchInput(e.target.value)}
           />
    @@ -101,7 +103,7 @@ export function ProductsFilters() {
            setTagsInput(e.target.value)}
           />
    @@ -114,7 +116,7 @@ export function ProductsFilters() {
             disabled={isPending}
           >
             
    -        Featured
    +        {t("featured")}
           
     
           {hasActiveFilters && (
    @@ -126,7 +128,7 @@ export function ProductsFilters() {
               disabled={isPending}
             >
               
    -          Clear Filters
    +          {t("clearFilters")}
             
           )}
         
    diff --git a/src/components/features/products/ProductsList.tsx b/src/components/features/products/ProductsList.tsx
    index 5c4f8cece..33958d937 100644
    --- a/src/components/features/products/ProductsList.tsx
    +++ b/src/components/features/products/ProductsList.tsx
    @@ -8,6 +8,7 @@ import { ShortcutHelp } from "@/components/features/keyboard/ShortcutHelp";
     import { useProductListKeyboardShortcuts } from "@/hooks/useProductListKeyboardShortcuts";
     import { Pagination } from "./Pagination";
     import styles from "./ProductList.module.css";
    +import { useTranslations } from "next-intl";
     
     export interface PaginationProps {
       hasNextPage: boolean;
    @@ -29,6 +30,7 @@ export function ProductsList({
       pagination,
     }: ProductsListProps) {
       const [showHelp, setShowHelp] = useState(false);
    +  const t = useTranslations("ProductsList");
     
       const { selectedIndex } = useProductListKeyboardShortcuts({
         products,
    @@ -39,7 +41,7 @@ export function ProductsList({
         return (
           
             
    -          No products found matching your criteria.
    +          {t("empty")}
             
           
         );
    @@ -47,7 +49,7 @@ export function ProductsList({
     
       return (
         
    -      
    @@ -163,7 +172,7 @@ export function MobileMenu({ onNavigate={close} icon={} > - New organization + {t("newOrganization")} )} @@ -173,7 +182,7 @@ export function MobileMenu({ onToggle={() => toggle("invitations")} label={ - Invitations + {t("invitations")} {hasInvitations && } } @@ -185,7 +194,7 @@ export function MobileMenu({ )) ) : ( - No pending invitations + {t("noInvitations")} )} @@ -193,11 +202,11 @@ export function MobileMenu({
    toggle("admin")} - label={Admin} + label={{t("admin")}} > {ADMIN_TOOLS.map((tool) => ( - {tool.name} + {tTools(`${tool.key}.name`)} ))}
    @@ -213,7 +222,7 @@ export function MobileMenu({ logout(); }} > - Log out + {t("logout")} )} @@ -223,6 +232,7 @@ export function MobileMenu({ /** Logged-out: the same hamburger sheet with just Products + login. */ export function LoggedOutMobileMenu() { + const t = useTranslations("MobileMenu"); return ( {(close) => ( @@ -232,10 +242,10 @@ export function LoggedOutMobileMenu() { className={styles.mobileRow} onClick={close} > - Products + {t("products")} - Docs + {t("docs")}
    {/* LoginButton is a full-page nav to the Ory flow; it carries its own diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1d4004986..16302b463 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -22,6 +22,606 @@ "none": "None", "unknownError": "Something went wrong. Please try again." }, + "AccountActions": { + "unauthenticated": "Unauthenticated", + "invalidFormData": "Invalid form data", + "unauthorizedCreate": "Unauthorized to create this type of account", + "accountIdTakenField": "That account ID is already taken.", + "accountIdTaken": "That account ID is already taken", + "accountIdRequired": "Account ID is required", + "accountNotFound": "Account not found", + "unauthorizedUpdate": "Unauthorized to update this account", + "invalidEmail": "Invalid email address", + "profileUpdated": "Profile updated successfully!", + "profileUpdateFailed": "Failed to update profile. Please try again.", + "unauthorizedUpdateFlags": "Unauthorized to update this account's flags", + "serviceAccountNoAdmin": "A service account cannot hold the admin flag", + "flagsUpdated": "Account flags updated successfully!", + "flagsUpdateFailed": "Failed to update account flags. Please try again." + }, + "AccountCreateDataConnectionPage": { + "metaTitle": "Create data connection", + "title": "Create Data Connection", + "description": "Connect external storage this account's products can mirror to." + }, + "AccountDataConnectionsPage": { + "metaTitle": "Data connections", + "title": "Data Connections", + "description": "Manage this account's connections to external storage.", + "newConnection": "New Connection" + }, + "AccountDropdown": { + "pendingInvitations": "You have pending invitations", + "you": "you", + "viewProfile": "View profile", + "viewOrganization": "View organization", + "products": "Products", + "noProducts": "No products yet", + "noCreateProductPermission": "You don't have permission to create products", + "newProduct": "New product", + "noCreateOrgPermission": "You don't have permission to create organizations", + "newOrganization": "New organization", + "invitations": "Invitations", + "noInvitations": "No pending invitations", + "admin": "Admin", + "logout": "Logout" + }, + "AccountFlagsForm": { + "createDataConnections": "Create Data Connections", + "createDataConnectionsOrgDescription": "Allows this organization to create and manage its own data connections to external storage.", + "createDataConnectionsDescription": "Allows this account to create and manage its own data connections to external storage.", + "createServiceAccounts": "Create Service Accounts", + "createServiceAccountsOrgDescription": "Allows this organization to create and manage service accounts for its software and pipelines.", + "createServiceAccountsDescription": "Allows this account to create and manage service accounts for its software and pipelines.", + "createProducts": "Create Products", + "createProductsDescription": "Allows this account to create new products and manage product settings.", + "createOrganizations": "Create Organizations", + "createOrganizationsDescription": "Allows this account to create new organizations and manage organizational accounts.", + "admin": "Administrator", + "adminDescription": "Full administrative access to the platform. Can manage all accounts, products, and system settings." + }, + "AccountInfoHoverCard": { + "serviceAccount": "Service account" + }, + "AccountMetadata": { + "title": "{name} · {type, select, individual {Individual} other {Organization}} · Source Cooperative", + "description": "{name} is an {type, select, individual {individual} other {organization}} on Source Cooperative" + }, + "AccountNotFoundPage": { + "title": "Account Not Found", + "description": "The requested account could not be found." + }, + "AccountSearchInput": { + "matchingAccounts": "Matching accounts", + "serviceAccount": "Service account" + }, + "ActivitySubmenu": { + "activity": "Activity", + "status": "{status, select, queued {queued} uploading {uploading} completed {completed} error {error} cancelled {cancelled} other {{status}}}", + "moreHidden": "and {count, number} more…" + }, + "AddGithubTrustDialog": { + "title": "Trust a GitHub workflow", + "invalid": "Name one repository and one branch, tag or environment", + "description": "One repository, pinned to one branch, tag or environment. GitHub vouches for the workflow at every run; nothing is stored here but the name.", + "trustIt": "Trust it" + }, + "AddSignInMenu": { + "description": "GitHub Actions workflows, each pinned to one repository and one branch, tag or environment; GitHub vouches for every run, so there is no secret to store. Or an API key, for environments without OIDC, shown once when it is issued.", + "addSignIn": "Add sign-in", + "githubWorkflow": "GitHub workflow", + "apiKey": "API key" + }, + "AdminAnalyticsPage": { + "metaTitle": "Admin — Analytics", + "heading": "Analytics", + "notConfigured": "Analytics is not configured. Set {vars}.", + "dateRange": "Date range (UTC)", + "rangeDays": "{days, plural, one {# day} other {# days}}", + "backRange": "Back {range} (the shown range)", + "forwardRange": "Forward {range} (the shown range)", + "backDay": "Back 1 day", + "forwardDay": "Forward 1 day", + "presetToday": "Today", + "presetDays": "{days}d", + "groupBy": "Group by", + "interval": "Interval", + "auto": "Auto", + "dimensions": { + "account": "Account", + "product": "Product", + "country": "Country", + "client": "IP hash" + }, + "intervals": { + "minute": "Minute", + "hourly": "Hourly", + "sixHour": "6-hour", + "daily": "Daily", + "weekly": "Weekly" + }, + "bucketMinutes": "{n}-minute", + "bucketHours": "{n}-hour", + "bucketDays": "{n}-day", + "spanHours": "{n, plural, one {# hour} other {# hours}}", + "spanDays": "{n, plural, one {# day} other {# days}}", + "intervalTooFine": "{interval} buckets fit ranges up to {span} — this range spans {range}", + "noTraffic": "No traffic recorded for this selection between {from} and {to}.", + "coarsened": "Showing {bucket} buckets — the requested interval would draw more than {max} bars over this range.", + "scope": "Scope", + "share": "Share", + "other": "Other" + }, + "AdminBreakdownChart": { + "avgBandwidth": "Avg bandwidth", + "bytes": "Bytes", + "viewSql": "View SQL", + "sqlTitle": "Analytics Engine SQL", + "sqlDescription": "The statements that produced this view, in execution order.", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit fullscreen", + "total": "Total", + "other": "Other", + "bucketUtc": "{date} UTC", + "bucketPlusDays": "{date} + {days}d", + "footnote": "Times are UTC. Values are estimates — high-volume traffic is measured from a sample. Click a bar (or drag across several) to zoom in; the browser back button zooms out." + }, + "AdminCreateDataConnectionPage": { + "metaTitle": "Admin — Create data connection", + "title": "Create Data Connection" + }, + "AdminDataConnectionsPage": { + "metaTitle": "Admin — Data connections", + "title": "Data Connections", + "newConnection": "New Connection" + }, + "AdminFiltersForm": { + "placeholderAccount": "account id", + "placeholderProduct": "product id", + "placeholderCountry": "country code (US)", + "placeholderClient": "ip hash prefix", + "placeholderValue": "value", + "fromAria": "From (UTC)", + "toAria": "To (UTC, exclusive)", + "utcHint": "UTC · end exclusive", + "filterBy": "Filter by", + "filterDimension": "Filter dimension", + "removeFilter": "Remove filter", + "addFilter": "Add filter", + "apply": "Apply" + }, + "AdminLayout": { + "title": "Admin" + }, + "AdminTools": { + "analytics": { + "name": "Analytics" + }, + "userLookup": { + "name": "User Lookup" + }, + "dataConnections": { + "name": "Data Connections" + } + }, + "AdminUserLookup": { + "disabled": "Disabled", + "oryNoIdentity": "Ory has no identity with the email “{query}”.", + "oryNoProfile": "Ory has an identity with the email “{query}”, but it has no source.coop profile.", + "oryResolved": "Resolved the email “{query}” through Ory.", + "databaseNoMatch": "No account handle or display name in the database contains “{query}”.", + "databaseMatches": "Handles and display names in the database containing “{query}”." + }, + "AdminUserLookupPage": { + "metaTitle": "Admin — User lookup", + "title": "User lookup", + "description": "Find users by username, name, or email and open their profiles." + }, + "AdminUserSearchField": { + "placeholder": "username, name, or user@example.com", + "ariaLabel": "Search users" + }, + "AnalyticsHelp": { + "downloads": "Number of successful downloads.", + "window": "Dates are UTC days. The range covers the most recent full days plus today so far.", + "served": "Total amount of data downloaded.", + "bandwidth": "Average rate that data was downloaded over this range.", + "requests": "Requests that successfully returned data.", + "countries": "How many different countries downloads came from.", + "dailyAvg": "Average downloads per day over the period.", + "uniqueIps": "How many different IP addresses downloaded data. Everyone sharing a network — an office, a university, a cloud provider — counts once, and very busy products may count a little low.", + "uniqueIpsSampled": "How many different IP addresses appear in this range. Busy ranges are measured from a sample, so this undercounts — the wider the range, the more it undercounts.", + "registered": "Downloads by signed-in users.", + "anon": "Downloads with no signed-in user.", + "distribution": "How many IP addresses downloaded once, twice, and so on. The line adds them up left to right, so at any bar it shows the share that downloaded that many times or fewer." + }, + "AnalyticsPanels": { + "downloads": "Downloads", + "dataServed": "Data served", + "countries": "Countries", + "requests": "Requests", + "uniqueIps": "Unique IPs", + "registered": "Registered", + "anonDownloads": "Anon downloads", + "windowDownloads": "{days}-day downloads", + "dailyDownloadsAria": "Daily downloads over the past {days} days", + "ipsByDownloadCount": "IPs by download count", + "noActivity": "No download activity in this period.", + "distributionAria": "Pareto chart of unique IPs by downloads per IP", + "tooltipIps": "{ips} IPs · {label} downloads", + "tooltipShare": "{share} of IPs at or below this", + "legendIps": "IPs", + "legendCumulative": "cumulative" + }, + "ApiKeyExpiryField": { + "expires": "Expires", + "days30": "30 days", + "days90": "90 days", + "year": "A year", + "never": "Never — until revoked" + }, + "ApiKeyList": { + "used": "Used {when}", + "neverUsed": "Never used", + "revoked": "Revoked {when}{via, select, person { by {who}} owner { in settings} holder { anonymously via the API} github { by GitHub secret scanning} other {}}", + "neverExpires": "Never expires", + "expires": "Expires {when}", + "expired": "Expired {when}", + "issued": "Issued {when} by {who}", + "lastUsed": "Last used {when}", + "actionsFor": "Actions for {label}", + "exampleUsage": "Example usage", + "changeExpiry": "Change expiry", + "revoke": "Revoke", + "signInWith": "Sign in with {label}", + "usageIntro": "Save the API key to a file and set environment variables accordingly:", + "markerRevoked": "Revoked", + "markerExpired": "Expired", + "expiryTitle": "When should {label} expire?", + "none": "None." + }, + "AuthButtons": { + "noProfile": "You do not yet have a profile. Please click here to complete your profile." + }, + "BreadcrumbNav": { + "root": "root" + }, + "CaseStudyCarousel": { + "readCaseStudy": "Read case study →", + "previousQuote": "Previous quote", + "nextQuote": "Next quote" + }, + "ChecksumVerifier": { + "notAvailable": "Checksum not available in response headers", + "failed": "Failed to verify checksum", + "verify": "Verify {algorithm}", + "verifying": "Verifying...", + "verified": "Checksum verified", + "mismatch": "Checksum mismatch" + }, + "ConditionalGroup": { + "because": "Because {because}" + }, + "ConnectionUsage": { + "heading": "Used by {count, plural, one {# product} other {# products}}", + "unused": "Nothing uses this connection, so it can be deleted.", + "columnProduct": "Product", + "columnRole": "Role", + "columnStatus": "Status", + "primary": "Primary", + "mirror": "Mirror", + "deactivated": "Deactivated", + "active": "Active" + }, + "ConnectionsEmpty": { + "title": "No data connections" + }, + "CopyToClipboard": { + "copyToClipboard": "Copy to clipboard" + }, + "CreateServiceAccountPage": { + "metaTitle": "Create service account", + "title": "New Service Account", + "description": "A login for software that you grant and revoke without sharing anyone's account." + }, + "DangerZone": { + "title": "Danger zone" + }, + "DataConnectionActions": { + "unauthenticated": "Unauthenticated", + "invalidFormData": "Invalid form data", + "nameTooShort": "Name needs at least {min} letters or numbers, so an ID can be made from it.", + "invalidName": "Invalid connection name", + "unauthorizedCreate": "Unauthorized to create data connections", + "nameTakenField": "That name is already taken — it would produce the ID \"{id}\".", + "nameTaken": "A data connection with that name already exists", + "created": "Data connection created successfully!", + "createFailed": "Failed to create data connection. Please try again.", + "idRequired": "Data connection ID is required", + "notFound": "Data connection not found", + "unauthorizedUpdate": "Unauthorized to update data connections", + "deletedConcurrently": "Data connection was deleted concurrently", + "updated": "Data connection updated successfully!", + "updateFailed": "Failed to update data connection. Please try again.", + "unauthorizedDelete": "Unauthorized to delete data connections", + "inUse": "Cannot delete: {count} product(s) still use this connection. Remove it from them first.", + "deleted": "Data connection deleted successfully!", + "deleteFailed": "Failed to delete data connection. Please try again." + }, + "DataConnectionEditPage": { + "metaTitle": "Edit data connection", + "adminMetaTitle": "Admin — Edit data connection", + "back": "Data connections", + "loadingUsage": "Loading product usage…", + "deleteTitle": "Delete this connection", + "deleteDescription": "Removes the connection record and its stored credentials. The bucket and its objects are not touched." + }, + "DataConnectionForm": { + "providerS3Description": "Or an S3-compatible backend: Cloudflare R2, MinIO, Ceph.", + "providerAzureDescription": "A storage account and one of its containers.", + "providerGcsDescription": "Keyless: federated through Workload Identity.", + "authAccessKey": "Access Key", + "authWebIdentityRole": "Web Identity Role (federated)", + "authSasToken": "SAS Token", + "authWorkloadIdentity": "Workload Identity (federated)", + "authAccessKeyDescription": "Static IAM access key and secret you provide.", + "authWebIdentityRoleDescription": "Keyless: the proxy assumes a customer IAM role via web identity.", + "authSasTokenDescription": "A shared access signature token you provide.", + "authAzureWorkloadIdentityDescription": "Keyless: the proxy federates into an Azure AD app registration.", + "authGcpWorkloadIdentityDescription": "Keyless: the proxy federates into a GCP service account via Workload Identity.", + "authNoneDescription": "How the data proxy authenticates to this backend when serving the product's data. Choose None for unsigned (public) access.", + "visibility": { + "public": "Public", + "unlisted": "Unlisted", + "restricted": "Restricted" + }, + "visibilityDescription": { + "public": "Products anyone can find and download.", + "unlisted": "Products reachable by link only.", + "restricted": "Products limited to their members." + }, + "sectionIdentity": "Identity", + "nameLabel": "Name", + "nameHelp": "Shown in admin lists and in each product's storage picker.", + "idLabel": "ID", + "idHelpEdit": "Permanent; renaming does not move it.", + "idHelpDerived": "Made from the name. Permanent once created.", + "idHelpEmpty": "Add a few letters or numbers to the name.", + "sectionBackend": "Backend", + "providerLabel": "Provider", + "providerHelp": "Decides which connection and authentication fields apply below.", + "becauseProviderS3": "provider is AWS S3", + "becauseProviderGcs": "provider is Google Cloud", + "becauseProviderAzure": "provider is Azure Blob", + "bucketLabel": "Bucket", + "bucketHelpS3": "Name of the S3 bucket that stores the data.", + "bucketHelpGcs": "Name of the Google Cloud Storage bucket.", + "regionLabel": "Region", + "regionHelpS3": "AWS region the bucket is hosted in. Use “auto” for S3-compatible backends like Cloudflare R2.", + "regionHelpAzure": "Azure region the storage account is hosted in.", + "regionPlaceholder": "Select a region", + "endpointLabel": "Endpoint", + "endpointHelp": "Custom S3-compatible endpoint for non-AWS backends (Cloudflare R2, MinIO, Ceph). Leave blank for AWS S3.", + "accountNameLabel": "Account Name", + "accountNameHelp": "Azure Storage account name.", + "containerNameLabel": "Container Name", + "containerNameHelp": "Azure Blob Storage container name.", + "sectionAuthentication": "Authentication", + "authTypeLabel": "Authentication Type", + "authTypeHelp": "The methods offered depend on the backend selected above.", + "authNone": "None (unsigned)", + "becauseMethodAccessKey": "method is Access Key", + "becauseMethodSasToken": "method is SAS Token", + "becauseMethodWebIdentityRole": "method is Web Identity Role", + "becauseMethodWorkloadIdentity": "method is Workload Identity", + "accessKeyIdLabel": "Access Key ID", + "accessKeyIdHelp": "Identifies which credential is in use. Not a secret — it is the paired secret access key that is never shown.", + "secretAccessKeyLabel": "Secret Access Key", + "secretAccessKeyHelp": "Paired with the access key ID. Stored encrypted and never shown again.", + "sasTokenLabel": "SAS Token", + "sasTokenHelp": "Shared access signature granting access to the container. Stored encrypted and never shown again.", + "roleArnLabel": "Role ARN", + "roleArnHelp": "IAM role the proxy assumes via AssumeRoleWithWebIdentity (keyless federation). This is an ARN, not a secret.", + "trustSubjectLabel": "Trust-policy subject", + "trustSubjectHelp": "The proxy presents this OIDC subject when assuming the role. In the role's trust policy, add a StringLike condition on data.source.coop:sub matching it, alongside data.source.coop:aud = sts.amazonaws.com.", + "tenantIdLabel": "Tenant ID", + "tenantIdHelp": "Azure AD tenant (directory) ID used for workload-identity federation.", + "clientIdLabel": "Client ID", + "clientIdHelp": "App registration (client) ID that holds the federated identity credential.", + "workloadIdentityProviderLabel": "Workload Identity Provider", + "workloadIdentityProviderHelp": "Full GCP Workload Identity provider resource. Not a secret.", + "serviceAccountLabel": "Service Account", + "serviceAccountHelp": "Email of the GCP service account the proxy impersonates. Not a secret.", + "sectionKeyLayout": "Key layout", + "basePrefixLabel": "Base Prefix", + "basePrefixHelp": "Optional shared root inside the bucket or container. Every product on this connection sits under it. Leave blank for the root.", + "prefixTemplateLabel": "Prefix Template", + "prefixTemplateHelp": "Where each product's objects land inside the bucket or container. {accountIdVar} and {repositoryIdVar} are substituted when a product attaches this connection.", + "examplePrefixLabel": "Example Prefix", + "examplePrefixHelp": "Where a product at {path} would be stored.", + "sectionPolicy": "Policy", + "readOnlyLabel": "Read only", + "readOnlyHelp": "Products can browse and download but never write. Required for unsigned connections.", + "allowedVisibilitiesLabel": "Allowed Visibilities", + "allowedVisibilitiesHelp": "Which visibilities a product on this connection may use. Checked when a product is created and whenever its visibility changes.", + "requiredFlagLabel": "Required Flag", + "requiredFlagHelp": "Account flag an owner must hold before this connection can back their products. Choose None for no restriction." + }, + "DataConnectionsList": { + "systemOwner": "system", + "emptyHint": "Create a data connection to get started.", + "readOnly": "Read only", + "permitsNothing": "permits nothing", + "listSeparator": ", ", + "visibility": { + "public": "public", + "unlisted": "unlisted", + "restricted": "restricted" + } + }, + "DeleteConnectionNote": { + "blocked": "Blocked: {count, plural, one {# product still uses it} other {# products still use it}}. Remove it from each first." + }, + "DeleteDataConnectionButton": { + "trigger": "Delete connection", + "confirmTitle": "Delete this connection?", + "confirmDescription": "This removes the connection record and its stored credentials. The bucket and its objects are not touched. It cannot be undone." + }, + "DeleteProductModal": { + "deleteFailed": "Failed to delete product", + "deleteProduct": "Delete Product", + "readOnlyTooltip": "This connection is read-only, so Source won't delete its underlying data.", + "recordOnlyLabel": "Delete the product record only", + "recordOnlyReadOnly": "This connection is read-only, so its underlying data is always kept.", + "recordOnlyDescription": "Remove the product and its memberships, but keep the underlying data in the connection.", + "confirmPrompt": "To confirm, type {expected} below:", + "consequencesHeading": "Deleting this product will:", + "consequenceRecord": "Remove the product record and its page", + "consequenceMemberships": "Remove all of its memberships", + "consequenceKeepData": "Keep the underlying data in the connection", + "consequenceDeleteData": "Delete all underlying data in the connection", + "cannotBeUndone": "This cannot be undone." + }, + "DeleteProgress": { + "dismiss": "Dismiss", + "deleted": "{count, plural, one {Deleted {shown} file} other {Deleted {shown} files}}", + "counting": "Counting files… {found} found", + "deletedOfTotal": "{count, plural, one {Deleted {deleted} of {total} file} other {Deleted {deleted} of {total} files}}" + }, + "DirectoryList": { + "empty": "This directory is empty.", + "scrollForMore": "Scroll for more" + }, + "DirectoryRow": { + "retryUpload": "Retry upload", + "cancelUpload": "Cancel upload", + "download": "Download {name}", + "copyUrl": "Copy URL to clipboard", + "deleteFailed": "Delete failed", + "deleteItem": "Delete {name}", + "deleteFolderTitle": "Delete folder", + "deleteFileTitle": "Delete file", + "deleteFolderConfirm": "Are you sure you want to delete {name} and everything inside it? This action cannot be undone.", + "deleteFileConfirm": "Are you sure you want to delete {name}? This action cannot be undone.", + "queued": "Queued...", + "uploading": "Uploading {percent}%", + "uploadError": "Error: {error}", + "cancelled": "Cancelled" + }, + "Dropzone": { + "dropToUpload": "Drop files here to upload" + }, + "EditAccountLayout": { + "details": "Details", + "profilePicture": "Profile Picture", + "dataConnections": "Data Connections", + "permissions": "Permissions", + "serviceAccounts": "Service Accounts", + "memberships": "Memberships", + "authentication": "Authentication", + "authenticationDisabledTooltip": "Admins can't edit another user's authentication details.", + "viewProfile": "View Profile" + }, + "EditAccountMembershipsPage": { + "metaTitle": "Edit {name} memberships", + "title": "Memberships", + "description": "Manage organization members and their roles", + "emptyTitle": "No members yet", + "emptyDescription": "Invite people to join your organization" + }, + "EditAccountPermissionsPage": { + "metaTitle": "Edit {name} permissions", + "title": "Permissions", + "description": "Manage this account's permissions and capabilities" + }, + "EditAccountProfilePage": { + "metaTitle": "Edit {name} profile", + "title": "Public Profile", + "descriptionIndividual": "Update your public profile information", + "descriptionOrganization": "Update your organization's public profile information" + }, + "EditAccountProfilePicturePage": { + "metaTitle": "{name} settings", + "title": "Profile Picture", + "descriptionIndividual": "Manage your account's profile picture", + "descriptionOrganization": "Manage your organization's profile picture" + }, + "EditProductDetailsPage": { + "metaTitle": "Edit {title} details", + "deleteTitle": "Delete this product", + "deleteDescription": "Permanent. Removes the product record, its memberships and its access records. Whether the underlying objects go too depends on the data connection." + }, + "EditProductLayout": { + "details": "Details", + "memberships": "Memberships", + "dataConnections": "Data Connections", + "viewProduct": "View Product" + }, + "EditProductMembershipsPage": { + "metaTitle": "Edit {title} memberships", + "title": "Memberships", + "description": "Manage product members and their roles", + "productMembers": "Product Members", + "productMembersDescription": "The following users have been explicitly granted access to this product", + "productMembersEmptyTitle": "No members yet", + "productMembersEmptyDescription": "Invite people to join your product", + "organizationMembers": "Organization Members", + "organizationMembersDescription": "The following users have implicit access to this product via their organization membership", + "organizationMembersEmptyTitle": "No organization members", + "organizationMembersEmptyDescription": "Organization has no members" + }, + "EditProductNotFound": { + "metaTitle": "Product Not Found | Source Cooperative", + "metaDescription": "The requested product could not be found.", + "notFound": "Not Found" + }, + "EditProfileForm": { + "sectionIdentity": "Identity", + "sectionAbout": "About", + "sectionIdentifiers": "Identifiers", + "sectionLinks": "Links", + "name": "Name", + "namePlaceholder": "Your Name", + "nameDescription": "This is the name that will be displayed on your profile", + "email": "Email", + "emailDescriptionIndividual": "Your primary email address. You can change it in your account settings.", + "emailDescriptionOrganization": "Contact email for your organization", + "bio": "Bio", + "description": "Description", + "bioPlaceholder": "Tell us about yourself", + "bioDescription": "A brief description of yourself or your work", + "descriptionPlaceholder": "Tell us about your organization", + "descriptionDescription": "A brief description of your organization", + "orcid": "ORCID ID", + "orcidDescription": "Your ORCID identifier (optional)", + "rorId": "ROR ID", + "rorIdDescription": "Your Research Organization Registry identifier (optional)", + "websites": "Websites", + "websitesDescription": "Add websites associated with your profile", + "addWebsite": "Add another website", + "removeWebsite": "Remove website" + }, + "EmailVerificationCallout": { + "unverified": "Please check your email. We've sent you a code to verify your email address. Or, you can click here to verify your email address.", + "justVerified": "Your email has been successfully verified. Thank you for confirming your account." + }, + "EmailVerificationStatus": { + "verifiedOn": "Email verified on {date}", + "notVerified": "Email not verified" + }, + "ExampleUsage": { + "githubWorkflowIntro": "Customize and save this workflow to a file under .github/workflows/:" + }, + "FetchCredentialsButton": { + "editMode": "Edit Mode", + "readOnly": "Read Only", + "viewCredentials": "View Credentials", + "uploadFiles": "Upload Files", + "uploadDirectory": "Upload Directory" + }, + "Field": { + "required": "(required)" + }, "Footer": { "aboutUs": "ABOUT US", "docs": "Docs", @@ -29,11 +629,732 @@ "allProducts": "All Products", "attribution": "Source Cooperative is a Radiant Earth project" }, + "GithubWorkflowFields": { + "repository": "Repository", + "repositoryHelp": "owner/repo, or with its permanent ids as owner@123/repo@456. About immutable subject claims", + "confirmed": "Confirmed via GitHub, subject claim prefix is repo:{repository}", + "customized": "This repository customizes its subject claim, so its tokens won't carry the subject below, and only subjects shaped like GitHub's default can be trusted. An admin can switch it back by selecting Use default template in its OIDC settings.", + "notVisible": "We can't see {repository}, so it's either private or doesn't exist. Check that it exists, then enter its default subject claim prefix as the repository. To find it:", + "unreachable": "We couldn't check {repository} with GitHub just now, most likely because it limits how often it can be asked. Try again in a few minutes, or enter its default subject claim prefix as the repository. To find it:", + "findAsAdmin": "As an admin of the repository, copy the Default subject claim prefix from its OIDC settings.", + "findWithCli": "Otherwise, run this with the GitHub CLI:", + "allowRunsFrom": "Allow runs from", + "allowRunsFromHelp": "A job's subject claim names its environment if one is specified within the workflow, otherwise its branch or tag. Pull request runs name neither, so they can't sign in. See GitHub's examples", + "kindBranch": "Branch", + "kindTag": "Tag", + "kindEnvironment": "Environment", + "trustsTokensMatching": "Trusts tokens matching" + }, + "GlobalUploadNotification": { + "title": "Uploads ({count, number})", + "uploadingCount": "{count, number} uploading", + "queuedCount": "{count, number} queued", + "all": "All ({count, number})", + "status": "{status, select, queued {queued} uploading {uploading} completed {completed} error {error} cancelled {cancelled} other {{status}}}", + "viewFile": "View file", + "viewDirectory": "View directory", + "clearCompleted": "Clear Completed", + "clearErrors": "Clear Errors", + "clearAll": "Clear All" + }, + "GrantProductDialog": { + "grantProduct": "Grant a product", + "product": "Product", + "productPlaceholder": "Choose a product", + "access": "Access", + "read": "Read", + "readWrite": "Read and write", + "grant": "Grant" + }, + "HeadingWithPermalink": { + "permalink": "Permalink" + }, + "HeroGlobe": { + "alt": "Visualization of live data requests around the world" + }, + "IndividualProfile": { + "websites": "{count, plural, one {Website} other {Websites}}", + "organizations": "Organizations", + "products": "Products", + "newProduct": "New product", + "noProducts": "No products available.", + "contributions": "Contributions" + }, + "InviteMemberForm": { + "account": "Account", + "accountDescription": "Search by username or name, or type an account ID. The organization's service accounts are offered too.", + "accountPlaceholder": "username or name", + "role": "Role", + "rolePlaceholder": "Select a role", + "roleDescription": "The role to assign to the new member", + "roleReader": "Reader", + "roleWriter": "Writer", + "roleMaintainer": "Maintainer", + "roleOwner": "Owner", + "invite": "Invite Member", + "title": "Invite New Member", + "description": "Invite a user to join {name} as a member.", + "submit": "Send invitation" + }, + "IssueApiKeyDialog": { + "title": "Issue an API key", + "done": "Done", + "description": "For environments without OIDC: a server, a scheduler, an instrument. The key signs in as this service account with exactly its grants.", + "label": "Label", + "labelHelp": "Where this key lives, so you know which one to revoke.", + "labelPlaceholder": "HPC cron job", + "addKey": "Add key", + "issueKey": "Issue key" + }, + "IssuedApiKey": { + "copyNow": "Copy the key now — this is the only time it is available in full.", + "hideKey": "Hide key", + "showKey": "Show key" + }, + "IssuedApiKeyDialog": { + "title": "API key issued", + "done": "Done" + }, + "LandingPage": { + "heroTitle": "The Cooperative Data Publishing Utility", + "heroTagline": "Data publishing at any scale for everyone.", + "heroBody": "Upload, share, and access data without needing to build or maintain your own infrastructure.", + "readDocs": "Read the docs →", + "exploreProducts": "Explore Data Products →", + "featuredEyebrow": "Explore Source Datasets", + "featuredHeading": "Featured Products", + "whatIsEyebrow": "What is Source Cooperative?", + "challengeHeading": "The Challenge", + "challenge1": "Scientific data infrastructure wasn't built for cross-border, cross-sector cooperation.", + "challenge2": "Addressing global challenges means combining data from governments, research institutions, commercial providers, and civil society—each with its own formats, access patterns, and APIs.", + "challenge3": "Data engineers, scientists, and researchers spend more time wrangling infrastructure than doing their actual work, hampering collaboration and limiting informed decision making.", + "solutionHeading": "Our Solution", + "solution1": "Source is a nonprofit data publishing utility built on commodity cloud object storage. Researchers publish data at any scale without running servers, building portals, or writing APIs.", + "solution2": "Source handles cloud infrastructure so researchers can focus on their data, not their hosting. Users access everything through standard URLs that work with existing tools—no custom portals, no proprietary APIs.", + "solution3": "The service is intentionally commoditized: built to resist lock-in and designed so that no single provider, funder, or political decision can make research data disappear.", + "whyEyebrow": "Why Source is different", + "whyHeading": "Data publishing for everyone", + "iconAlt": "icon", + "integrateHeading": "Integrate into any workflow", + "integrateBody": "Source is a vendor-neutral data publishing utility that works with any S3-compatible cloud provider.", + "neutralHeading": "Neutral hosting, mission backed", + "neutralBody": "Retain full ownership and control of your data. Source supports open formats and is a non-commercial repository, so you're never trapped by proprietary APIs or workflows.", + "pricingHeading": "Flat pricing", + "pricing1": "Instead of building and maintaining data portals or trying to wrangle variable cloud costs, Source lets you focus on the fundamentals that make data easy to publish and easy to use.", + "pricing2": "Pricing plans based on data volume and usage will be announced soon.", + "caseStudiesEyebrow": "Case Studies", + "caseStudiesHeading": "Open Source, Governed, and Trusted By These Teams", + "builtForHeading": "Built for Data Engineers, Scientists, and Developers", + "builtForBody": "Source Cooperative supports your data lifecycle, from prototyping to public release, without navigating corporate cloud policies.", + "exploreData": "Explore the data →" + }, + "LiveGlobe": { + "aria": "Interactive 3D globe showing live data request locations", + "requests": "{count, plural, one {# request} other {# requests}}" + }, "LocaleSwitcher": { "label": "Language" }, + "LoginButton": { + "logInOrRegister": "Log In / Register" + }, + "LoginRequired": { + "signIn": "Sign in" + }, + "MembershipActions": { + "unauthenticated": "Unauthenticated", + "missingFields": "Missing required fields", + "organizationNotFound": "Organization not found", + "inviteeNotFound": "Account to invite not found", + "organizationsCannotBeMembers": "Organizations cannot be members", + "unauthorizedInvite": "Unauthorized to invite members to this organization", + "alreadyMember": "User is already a member or has a pending invitation", + "invited": "Member invited successfully!", + "inviteFailed": "Failed to invite member. Please try again.", + "membershipNotFound": "Membership not found", + "unauthorizedAccept": "Unauthorized to accept this invitation", + "notPending": "Membership is not in a pending state", + "acceptFailed": "Failed to accept invitation", + "unauthorizedReject": "Unauthorized to reject this invitation", + "rejectFailed": "Failed to reject invitation", + "missingMembershipId": "Missing membership ID", + "unauthorizedRevoke": "Unauthorized to revoke this membership", + "revoked": "Membership revoked successfully!", + "revokeFailed": "Failed to revoke membership. Please try again." + }, + "MembershipsTable": { + "member": "Member", + "role": "Role", + "status": "Status", + "lastUpdated": "Last Updated", + "actions": "Actions", + "serviceAccount": "Service account", + "roleOwner": "Owner", + "roleMaintainer": "Maintainer", + "roleWriter": "Writer", + "roleReader": "Reader", + "stateMember": "Member", + "stateInvited": "Invited", + "stateRevoked": "Revoked", + "unknown": "Unknown", + "revoke": "Revoke" + }, + "MobileMenu": { + "openMenu": "Open menu", + "closeMenu": "Close menu", + "menu": "Menu", + "products": "Products", + "docs": "Docs", + "you": "you", + "viewProfile": "View profile", + "viewOrganization": "View organization", + "noProducts": "No products yet", + "newProduct": "New product", + "newOrganization": "New organization", + "invitations": "Invitations", + "noInvitations": "No pending invitations", + "admin": "Admin", + "logout": "Log out" + }, "NavLinks": { "products": "Products", "docs": "Docs" + }, + "NewOrganizationPage": { + "accessDenied": "Access Denied", + "loginRequired": "You must be logged in to create an organization.", + "title": "Create New Organization", + "description": "Create a new organization to collaborate with others" + }, + "NewProductPage": { + "accessDenied": "Access Denied", + "mustLogIn": "You must be logged in to create a product.", + "noPermission": "You do not have permission to create products.", + "contactUs": "If you believe this is an error, please contact {email}.", + "title": "Create New Product", + "description": "Create a new product to share with others" + }, + "NotFoundMetadata": { + "title": "Page Not Found", + "description": "The page you are looking for does not exist." + }, + "ObjectPreviewExternal": { + "noPreview": "No preview available for file type .{extension}. Open an issue if you would like support for this file type.", + "previewTitle": "Preview of {path}" + }, + "ObjectPreviewSlot": { + "title": "Object Preview", + "openInNewTab": "Open in new tab", + "iframeTitle": "Preview of {path}" + }, + "ObjectSummary": { + "name": "Name", + "size": "Size", + "contentType": "Content Type", + "lastModified": "Last Modified", + "checksum": "Checksum", + "sourceUrl": "Source URL", + "cloudUri": "Cloud URI", + "download": "Download" + }, + "OnboardingForm": { + "username": "Username", + "usernameDescription": "This will be your profile URL: source.coop/[username]", + "usernamePlaceholder": "Choose a username", + "checking": "Checking username availability...", + "available": "✓ Username is available", + "fullName": "Full Name", + "fullNameDescription": "This is the name that will be displayed on your profile", + "fullNamePlaceholder": "Your Name", + "submit": "Complete profile" + }, + "OnboardingPage": { + "title": "Complete Your Profile", + "metaDescription": "Choose your username and set up your profile", + "description": "You're almost done! Choose a username for your account and tell us your name." + }, + "OrganizationCreationForm": { + "name": "Organization Name", + "nameDescription": "The name of your organization", + "namePlaceholder": "Enter organization name", + "accountId": "Account ID", + "accountIdDescription": "The account ID of your organization", + "accountIdPlaceholder": "Enter account ID", + "checking": "Checking account ID availability...", + "available": "✓ Available", + "description": "Description", + "descriptionDescription": "A brief description of your organization", + "descriptionPlaceholder": "Describe your organization", + "website": "Website", + "websiteDescription": "Your organization's website (optional)", + "email": "Email", + "emailDescription": "Contact email for your organization (optional)", + "submit": "Create" + }, + "OrganizationMembers": { + "empty": "No members to display", + "owners": "Owners", + "admins": "Administrators", + "members": "Members" + }, + "OrganizationProfile": { + "details": "Organization Details", + "emailLabel": "Email:", + "rorIdLabel": "ROR ID:", + "members": "Members", + "products": "Products", + "newProduct": "New product", + "noProducts": "No products available." + }, + "PendingInvitationBanner": { + "acceptFailed": "Failed to accept invitation", + "acceptError": "An error occurred while accepting the invitation", + "rejectFailed": "Failed to reject invitation", + "rejectError": "An error occurred while rejecting the invitation", + "roleOwners": "Owner", + "roleMaintainers": "Maintainer", + "roleReadData": "Data Reader", + "roleWriteData": "Data Writer", + "message": "You have been invited to join this {invitationType, select, product {product} other {organization}} as {role, select, owners {an} other {a}} {roleName}.", + "processing": "Processing...", + "accept": "Accept", + "decline": "Decline" + }, + "PreviewIframe": { + "unsupported": "Your browser does not support iframes." + }, + "ProductAccessList": { + "noProducts": "{owner} has no products yet.", + "empty": "Nothing yet. Grant a product to let it read or write data.", + "opensInNewTab": "opens in a new tab", + "accessTo": "Access to {product}", + "removeAccess": "Remove access to {product}", + "read": "Read", + "readWrite": "Read and write" + }, + "ProductActions": { + "unauthenticated": "Unauthenticated", + "invalidFormData": "Invalid form data", + "unauthorizedCreate": "Unauthorized to create product", + "connectionRequired": "A data connection is required", + "connectionNotFoundField": "Selected data connection was not found", + "invalidConnection": "Invalid data connection", + "connectionNotPermitted": "You are not permitted to use the selected data connection", + "connectionWrongAccountField": "Selected data connection is not available for this account", + "connectionWrongAccount": "Invalid data connection for this account", + "visibilityNotAllowed": "The \"{name}\" data connection does not allow {visibility} products", + "invalidVisibility": "Invalid visibility for the selected data connection", + "idsRequired": "Account ID and Product ID are required", + "productNotFound": "Product not found", + "unauthorizedUpdate": "Unauthorized to update this product", + "connectionMissingField": "This product's data connection could not be found, so its visibility cannot be changed", + "connectionMissing": "Data connection not found for this product", + "invalidVisibilityForProduct": "Invalid visibility for the product's data connection", + "updated": "Product updated successfully!", + "updateFailed": "Failed to update product. Please try again.", + "unauthorizedDelete": "Unauthorized to delete this product", + "cannotPreserveData": "You are not permitted to keep this product's data when deleting it.", + "deleteFailed": "Failed to delete product: {reason}" + }, + "ProductAnalyticsPage": { + "metaTitle": "{title} — Analytics", + "heading": "Analytics", + "previewNotice": "Analytics is a preview feature. The metrics shown here and who can access them may change in the near future. Let us know what you think at hello@source.coop.", + "windowDays": "{days}d", + "unavailable": "Analytics are unavailable right now. Try again in a few minutes." + }, + "ProductAnalyticsView": { + "downloadsTab": "DOWNLOADS", + "usersTab": "USERS", + "dailyAvg": "Daily avg", + "byCountry": "By country", + "breakdownUnavailable": "Country breakdown unavailable.", + "otherCountries": "{count} others", + "unknownCountry": "Unknown", + "topFiles": "Top files", + "noFileDownloads": "No file downloads in this period.", + "file": "File" + }, + "ProductContentsCard": { + "contents": "Contents" + }, + "ProductCreationForm": { + "visibility": { + "public": { + "label": "Public", + "description": "Anyone can find and download it. Appears in search and the product feed." + }, + "unlisted": { + "label": "Unlisted", + "description": "Anyone with the link can download it. Hidden from search and the feed." + }, + "restricted": { + "label": "Restricted", + "description": "Members of this product only." + } + }, + "noVisibilities": "no visibilities", + "connectionOption": "{name}{location} · {visibilities}", + "connectionOptionReadOnly": "{name}{location} · {visibilities} · Read Only", + "sectionDescription": "Description", + "sectionStorage": "Storage", + "sectionAccess": "Access", + "titleLabel": "Product Title", + "titleDescription": "The name of your product", + "titlePlaceholder": "Enter product name", + "ownerLabel": "Owner Account", + "ownerDescription": "The account that owns the product", + "productIdLabel": "Product ID", + "productIdDescription": "The ID of your product", + "productIdPlaceholder": "Enter product ID", + "checkingAvailability": "Checking availability of {id}", + "available": "✓ Available: {id}", + "descriptionLabel": "Description", + "descriptionDescription": "A brief description of your product", + "descriptionPlaceholder": "Describe your product", + "connectionLabel": "Data Connection", + "connectionDescription": "Where this product's data is stored. Determines the available region and visibility options. If you're unsure, choose a us-west-2 connection.", + "noConnections": "No data connections available", + "visibilityLabel": "Visibility", + "visibilityConnectionMissing": "This product's data connection could not be found, so its visibility can't be changed.", + "visibilityDescription": "Who can reach this product. The options depend on its data connection.", + "statusLabel": "Status", + "statusActive": "Active", + "statusDeactivated": "Deactivated", + "statusDescription": "Deactivating hides the product from source.coop and blocks the data.source.coop API. The data is kept, but only a Source Cooperative administrator can reactivate it." + }, + "ProductDataUnavailable": { + "accessUnconfirmed": "Your access to this private product's data couldn't be confirmed just now.", + "errorDetails": "Error details (visible to maintainers only)", + "tryAgain": "Try again" + }, + "ProductErrorPage": { + "heading": "Something went wrong loading this product", + "body": "We couldn't load this product's contents right now. This is usually temporary — please try again in a moment.", + "reference": "Reference: {digest}", + "tryAgain": "Try again" + }, + "ProductLayout": { + "deactivatedNotice": "This product is deactivated. It is hidden from everyone except its owners and administrators." + }, + "ProductListItem": { + "providedBy": "Provided by ", + "publishedOn": "Published on ", + "public": "Public", + "unlisted": "Unlisted", + "restricted": "Restricted", + "publicProduct": "Public product", + "unlistedProduct": "Unlisted product", + "restrictedProduct": "Restricted product", + "deactivated": "Deactivated", + "deactivatedProduct": "Deactivated product" + }, + "ProductLoading": { + "contents": "Contents", + "details": "Details" + }, + "ProductMetaCard": { + "details": "Details" + }, + "ProductMetaContent": { + "visibility": "Visibility", + "public": "Public", + "unlisted": "Unlisted", + "restricted": "Restricted", + "owner": "Owner", + "created": "Created", + "lastUpdated": "Last Updated" + }, + "ProductMetadata": { + "unknownAccount": "Unknown Account", + "description": "A data product by {account}" + }, + "ProductMirrorActions": { + "notAccountManager": "Only owners and maintainers of the owning account can manage this product's data connections", + "concurrentEdit": "This product was modified by someone else. Please reload and try again.", + "unauthenticated": "Unauthenticated", + "missingFields": "Missing required fields", + "productNotFound": "Product not found", + "connectionNotFound": "Data connection not found", + "connectionUnavailable": "This data connection is not available for this account", + "alreadyAssociated": "This data connection is already associated with this product", + "added": "Data connection added successfully!", + "addFailed": "Failed to add data connection. Please try again.", + "mirrorNotFound": "Mirror not found", + "removed": "Data connection removed successfully!", + "removeFailed": "Failed to remove data connection. Please try again.", + "invalidPrefix": "Prefix can't start with '/' or contain '..'", + "cannotEditPrefix": "You must be able to manage both this account and the data connection to edit its prefix", + "prefixUpdated": "Prefix updated successfully!", + "prefixUpdateFailed": "Failed to update prefix. Please try again.", + "primaryUpdated": "Primary mirror updated successfully!", + "primaryUpdateFailed": "Failed to update primary mirror. Please try again." + }, + "ProductMirrorsManager": { + "title": "Data Connections", + "description": "Where this product's objects live. The primary connection is the one visitors download from.", + "readOnlyNotice": "Only owners and maintainers of {account} can change this product's data connections.", + "cacheNotice": "The data proxy caches these settings. Changes here can take up to five minutes to take effect.", + "emptyManage": "Add a data connection to this product.", + "emptyReadOnly": "No data connections have been configured for this product.", + "primary": "Primary", + "actionsFor": "Actions for {name}", + "makePrimary": "Make primary", + "editConnection": "Edit connection", + "removeFromProduct": "Remove from product…", + "prefixLabel": "Prefix", + "prefixHelp": "Where this product's objects sit inside the connection. You can change it because you manage both this product's account and its connection.", + "connectionRoot": "(connection root)", + "addTitle": "Add a connection", + "noneAvailable": "No other connections are available to this account.", + "allAttached": "Every connection available to this account is already attached.", + "choosePlaceholder": "Choose a connection…", + "add": "Add", + "removeConfirmTitle": "Remove {name}?", + "removeConfirmDescription": "This product will no longer be served from that storage. The objects themselves are not deleted, and you can attach the connection again later." + }, + "ProductNotFoundPage": { + "metaTitle": "Product Not Found | Source Cooperative", + "metaDescription": "The requested product could not be found.", + "title": "Not Found" + }, + "ProductPage": { + "dataUnavailable": "This product's files couldn't be loaded just now — the storage backend didn't respond. Try again in a moment." + }, + "ProductSearchResult": { + "updated": "Updated ", + "public": "Public", + "private": "Private" + }, + "ProductTabs": { + "product": "PRODUCT", + "analytics": "ANALYTICS", + "restrictedTooltip": "Visible only to this product's owners, maintainers, and site admins.", + "restricted": "Restricted" + }, + "ProductsFilters": { + "searchPlaceholder": "Search products...", + "tagsPlaceholder": "Filter by tags (comma-separated)", + "featured": "Featured", + "clearFilters": "Clear Filters" + }, + "ProductsList": { + "empty": "No products found matching your criteria.", + "navLabel": "Product list" + }, + "ProductsPage": { + "metaTitle": "Products | Source Cooperative", + "metaDescription": "Browse and discover public data products on Source.coop", + "title": "Products", + "showingCount": "Showing {count, plural, one {# product} other {# products}}", + "searchBadge": "Search: “{search}”", + "tagsBadge": "Tags: {tags}", + "featured": "Featured" + }, + "ProductsSkeleton": { + "navLabel": "Product list loading" + }, + "ProfileImageUpload": { + "invalidType": "Invalid file type. Allowed types: {types}", + "tooLarge": "File size must be less than {size}MB", + "uploadFailedStatus": "Upload failed: {status}", + "uploadFailed": "Upload failed", + "deleteFailed": "Delete failed", + "titleIndividual": "Your Profile Picture", + "titleOrganization": "Organization Profile Picture", + "descriptionIndividual": "Upload a profile picture to personalize your account. If no custom image is uploaded, your Gravatar will be displayed.", + "descriptionOrganization": "Upload a profile picture for your organization. If no custom image is uploaded, a default icon will be displayed.", + "uploading": "Uploading...", + "uploadNew": "Upload New Image", + "deleting": "Deleting...", + "removeImage": "Remove Image", + "removeTitle": "Remove Profile Image", + "removeConfirmIndividual": "Are you sure you want to remove your profile image? Your account will fall back to using your Gravatar.", + "removeConfirmOrganization": "Are you sure you want to remove your profile image? Your organization will display the default icon.", + "removed": "✓ Profile image removed successfully!", + "uploaded": "✓ Profile image uploaded successfully!", + "guidelines": "Image Guidelines", + "guidelineDimensions": "• Recommended dimensions: 400x400 pixels or larger", + "guidelineSquare": "• Square images work best for profile pictures", + "guidelineFormats": "• Supported formats: JPG, PNG, WebP, GIF", + "guidelineSize": "• Maximum file size: 5MB" + }, + "ProxyCredentialsGate": { + "error": "We couldn't load your access credentials for this private product. Please try again, and if the problem persists, sign out and back in.", + "tryAgain": "Try again", + "fetching": "Fetching access credentials…" + }, + "RefreshListingButton": { + "refresh": "Refresh", + "refreshListing": "Refresh listing" + }, + "STACSection": { + "catalog": "STAC Catalog: {url}" + }, + "SecretField": { + "keepCurrent": "Keep current", + "stored": "Stored", + "replace": "Replace…" + }, + "ServiceAccountActions": { + "unauthenticated": "Unauthenticated", + "checkFields": "Check the highlighted fields", + "notOwnerManager": "You do not manage service accounts for that account", + "cannotOwnAnother": "A service account cannot own another", + "badSubject": "{subject} does not name one repository and one branch, tag or environment", + "accessReadOrWrite": "{product}: access must be read or write", + "noSuchProduct": "{account} has no product {product}", + "expiryRange": "Expiry must be between 1 and 3650 days", + "keyLabelTooLong": "Up to 64 characters.", + "accountIdTaken": "That account ID is already taken", + "localIdTaken": "{owner} already has a service account called {id}.", + "notManaged": "You do not manage that service account", + "disabled": "That service account is disabled", + "nameOneRepo": "Name one repository and one branch, tag or environment", + "alreadyTrusted": "Already trusted", + "trusted": "Trusted", + "noSuchTrust": "No such trust on this account", + "trustRemoved": "Trust removed", + "accountDisabled": "Service account disabled", + "accountEnabled": "Service account enabled", + "nameSaved": "Name saved" + }, + "ServiceAccountDetail": { + "actionsFor": "Actions for {subject}", + "exampleUsage": "Example usage", + "signInFromWorkflow": "Sign in from this workflow", + "whoItIs": "Who it is", + "disabled": "Disabled", + "name": "Name", + "accountId": "Account ID", + "accountIdHelp": "The handle software signs in as. Permanent once created.", + "signsInWith": "Signs in with", + "signInDescription": "GitHub Actions workflows, each pinned to one repository and one branch, tag or environment; GitHub vouches for every run, so there is no secret to store. Or an API key, for environments without OIDC, shown once when it is issued. Revoke a key that leaks, or disable the account below to stop every sign-in at once.", + "nothingYet": "Nothing yet — it cannot sign in until a workflow is trusted or a key is issued.", + "githubWorkflows": "GitHub workflows", + "apiKeys": "API keys", + "canReach": "Can reach", + "canReachDescription": "The products it can read or write, each opened in a new tab to check what it holds. A change is saved at once, and takes effect on its next sign-in.", + "dangerZone": "Danger zone", + "enable": "Enable", + "disable": "Disable", + "enableTitle": "Enable {name}?", + "disableTitle": "Disable {name}?", + "enableDescription": "Its trusted workflows and unrevoked API keys can sign in again, so revoke any key that leaked first.", + "disableDescription": "Nothing can sign in as it, by workflow or by API key, and within five minutes credentials already generated are cut back to public data. Its workflows, keys and product access are kept.", + "deleteTitle": "Delete {name}?", + "deleteDescription": "Its workflows and API keys stop working, and it loses access to every product. Credentials already generated last until they expire. This cannot be undone.", + "typeToConfirm": "Type {accountId} to confirm" + }, + "ServiceAccountForm": { + "removeLabel": "Remove {label}", + "whoItIs": "Who it is", + "ownedBy": "Owned by {owner}; whoever manages that account manages this one.", + "name": "Name", + "namePlaceholder": "Nightly Sync", + "accountId": "Account ID", + "accountIdEditHelp": "The handle software signs in as. Lowercase letters, numbers and single hyphens.", + "accountIdHelp": "Made from the name. Permanent once created.", + "signsInWith": "Signs in with", + "nothingYet": "Nothing yet. Add a workflow or key to grant access from an external environment.", + "githubWorkflows": "GitHub workflows", + "apiKeys": "API keys", + "expiresAfter": "{days, plural, one {Expires # day after it is issued} other {Expires # days after it is issued}}", + "neverExpires": "Never expires", + "canReach": "Can reach", + "canReachDescription": "Products {owner} owns that it may read or write. Each grant is an ordinary membership, revoked the same way as a person's.", + "submit": "Create service account" + }, + "ServiceAccountKeyActions": { + "notManaged": "You do not manage that service account", + "disabled": "That service account is disabled", + "expiryRange": "Expiry must be between 1 and 3650 days", + "labelTooLong": "Give the key a label of up to 64 characters", + "noSuchKey": "No such key on a service account you manage", + "alreadyRevoked": "Already revoked", + "revoked": "Key revoked", + "expiryUpdated": "Expiry updated", + "noExpiry": "Key no longer expires" + }, + "ServiceAccountList": { + "emptyTitle": "No service accounts yet", + "emptyBody": "Create one for a nightly sync, a publishing pipeline, or an instrument.", + "disabled": "Disabled", + "workflows": "{count, plural, one {# workflow} other {# workflows}}", + "apiKeys": "{count, plural, one {# API key} other {# API keys}}", + "products": "{count, plural, one {# product} other {# products}}" + }, + "ServiceAccountPage": { + "metaTitle": "Service account", + "back": "Service accounts" + }, + "ServiceAccountsPage": { + "metaTitle": "Service accounts", + "title": "Service Accounts", + "description": "Logins for software — a nightly sync, a publishing pipeline, an instrument — granted and revoked without sharing anyone's account.", + "newServiceAccount": "New service account" + }, + "ShortcutHelp": { + "title": "Keyboard Shortcuts", + "descriptionProductList": "Keyboard shortcuts for product list. Use these shortcuts to navigate and interact with the interface.", + "descriptionObjectBrowser": "Keyboard shortcuts for object browser. Use these shortcuts to navigate and interact with the interface.", + "descriptionObjectDetails": "Keyboard shortcuts for object details. Use these shortcuts to navigate and interact with the interface.", + "keyHeader": "Key", + "actionHeader": "Action", + "pressEscapeToClose": "Press Escape to close", + "toggleHelp": "Show/hide this help dialog", + "goHome": "Go to homepage", + "navigateUp": "Navigate up", + "navigateDown": "Navigate down", + "openSelectedProduct": "Open selected product", + "copySelectedProductUrl": "Copy URL of selected product", + "clearSelection": "Clear selection", + "openSelectedItem": "Open selected item", + "copySelectedItemUrl": "Copy URL of selected item", + "goUpOneLevel": "Go up one level", + "navigateUpProperties": "Navigate up through properties", + "navigateDownProperties": "Navigate down through properties", + "copySelectedPropertyValue": "Copy selected property value", + "returnToDirectoryView": "Return to directory view" + }, + "SiteMetadata": { + "description": "A data publishing utility for the open web." + }, + "StatusPage": { + "notFoundTitle": "404: Not Found", + "notFoundDescription": "We couldn't find what you're looking for.

    The path may have been moved or no longer exists.", + "notAuthorizedTitle": "403: Not Authorized", + "notAuthorizedDescription": "You don't have permission to access this resource.

    Please contact an administrator if you believe this is an error.", + "unauthenticatedTitle": "Sign in required", + "unauthenticatedDescription": "You need to sign in to access this page.

    You'll be returned here after logging in.", + "returnHome": "Return to Homepage" + }, + "UploadProvider": { + "leaveWarning": "Uploads or deletes in progress will be interrupted if you leave.", + "requestFailed": "request failed", + "deletePartial": "Delete may be partial — {reason}. Retry to remove remaining items.", + "deleteFailed": "Delete failed — {reason}. Please retry." + }, + "UsageCard": { + "title": "Analytics", + "windowDays": "{days} days" + }, + "ViewCredentialsDialog": { + "title": "View Credentials", + "description": "Copy these temporary credentials to use in your applications.", + "cliCallout": "Prefer a simpler workflow? The Source Coop CLI handles credentials for you, with longer-lived credentials (up to 12 hours).", + "tabJson": "JSON (SDK)", + "tabEnv": "Environment Variables", + "jsonHint": "For SDK clients (boto3, AWS SDK, etc.)", + "envHint": "For terminal/shell usage", + "iniHint": "For ~/.aws/credentials (AWS CLI profile)", + "boundaries": "Boundaries", + "expiration": "Expiration", + "bucket": "Bucket", + "prefix": "Prefix", + "copyToClipboard": "Copy to clipboard" + }, + "WelcomeCallout": { + "message": "Welcome to Source Cooperative. This is your profile page. Edit it to add more details." } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 760216461..7d2c54100 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -22,6 +22,606 @@ "none": "なし", "unknownError": "問題が発生しました。もう一度お試しください。" }, + "AccountActions": { + "unauthenticated": "ログインが必要です。", + "invalidFormData": "入力内容が正しくありません。", + "unauthorizedCreate": "この種類のアカウントを作成する権限がありません。", + "accountIdTakenField": "そのアカウントIDはすでに使用されています。", + "accountIdTaken": "そのアカウントIDはすでに使用されています。", + "accountIdRequired": "アカウントIDは必須です。", + "accountNotFound": "アカウントが見つかりません。", + "unauthorizedUpdate": "このアカウントを更新する権限がありません。", + "invalidEmail": "メールアドレスが正しくありません。", + "profileUpdated": "プロフィールを更新しました。", + "profileUpdateFailed": "プロフィールを更新できませんでした。もう一度お試しください。", + "unauthorizedUpdateFlags": "このアカウントのフラグを更新する権限がありません。", + "serviceAccountNoAdmin": "サービスアカウントに管理者フラグは付与できません。", + "flagsUpdated": "アカウントのフラグを更新しました。", + "flagsUpdateFailed": "アカウントのフラグを更新できませんでした。もう一度お試しください。" + }, + "AccountCreateDataConnectionPage": { + "metaTitle": "データ接続を作成", + "title": "データ接続を作成", + "description": "このアカウントのプロダクトがミラーできる外部ストレージを接続します。" + }, + "AccountDataConnectionsPage": { + "metaTitle": "データ接続", + "title": "データ接続", + "description": "このアカウントの外部ストレージへの接続を管理します。", + "newConnection": "新しい接続" + }, + "AccountDropdown": { + "pendingInvitations": "保留中の招待があります", + "you": "あなた", + "viewProfile": "プロフィールを表示", + "viewOrganization": "組織を表示", + "products": "プロダクト", + "noProducts": "プロダクトはまだありません", + "noCreateProductPermission": "プロダクトを作成する権限がありません", + "newProduct": "新規プロダクト", + "noCreateOrgPermission": "組織を作成する権限がありません", + "newOrganization": "新規組織", + "invitations": "招待", + "noInvitations": "保留中の招待はありません", + "admin": "管理者", + "logout": "ログアウト" + }, + "AccountFlagsForm": { + "createDataConnections": "データ接続の作成", + "createDataConnectionsOrgDescription": "この組織が外部ストレージへの独自のデータ接続を作成・管理できるようにします。", + "createDataConnectionsDescription": "このアカウントが外部ストレージへの独自のデータ接続を作成・管理できるようにします。", + "createServiceAccounts": "サービスアカウントの作成", + "createServiceAccountsOrgDescription": "この組織がソフトウェアやパイプライン用のサービスアカウントを作成・管理できるようにします。", + "createServiceAccountsDescription": "このアカウントがソフトウェアやパイプライン用のサービスアカウントを作成・管理できるようにします。", + "createProducts": "プロダクトの作成", + "createProductsDescription": "このアカウントが新しいプロダクトを作成し、プロダクトの設定を管理できるようにします。", + "createOrganizations": "組織の作成", + "createOrganizationsDescription": "このアカウントが新しい組織を作成し、組織アカウントを管理できるようにします。", + "admin": "管理者", + "adminDescription": "プラットフォームへの完全な管理者権限です。すべてのアカウント、プロダクト、システム設定を管理できます。" + }, + "AccountInfoHoverCard": { + "serviceAccount": "サービスアカウント" + }, + "AccountMetadata": { + "title": "{name} · {type, select, individual {個人アカウント} other {組織}} · Source Cooperative", + "description": "{name}はSource Cooperativeの{type, select, individual {個人アカウント} other {組織}}です。" + }, + "AccountNotFoundPage": { + "title": "アカウントが見つかりません", + "description": "リクエストされたアカウントは見つかりませんでした。" + }, + "AccountSearchInput": { + "matchingAccounts": "一致するアカウント", + "serviceAccount": "サービスアカウント" + }, + "ActivitySubmenu": { + "activity": "アクティビティ", + "status": "{status, select, queued {待機中} uploading {アップロード中} completed {完了} error {エラー} cancelled {キャンセル済み} other {{status}}}", + "moreHidden": "他{count, number}件…" + }, + "AddGithubTrustDialog": { + "title": "GitHub ワークフローを信頼", + "invalid": "リポジトリを1つと、ブランチ、タグ、または環境を1つ指定してください", + "description": "1つのリポジトリを、1つのブランチ、タグ、または環境に固定します。実行のたびに GitHub がワークフローを保証するため、ここに保存されるのは名前だけです。", + "trustIt": "信頼する" + }, + "AddSignInMenu": { + "description": "GitHub Actions のワークフロー(それぞれ1つのリポジトリと、1つのブランチ、タグ、または環境に固定)。実行のたびに GitHub が保証するため、保存すべきシークレットはありません。または、OIDC が使えない環境向けの APIキー(発行時に一度だけ表示)。", + "addSignIn": "ログイン方法を追加", + "githubWorkflow": "GitHub ワークフロー", + "apiKey": "APIキー" + }, + "AdminAnalyticsPage": { + "metaTitle": "管理者 — アナリティクス", + "heading": "アナリティクス", + "notConfigured": "アナリティクスが設定されていません。{vars}を設定してください。", + "dateRange": "期間(UTC)", + "rangeDays": "{days, plural, other {#日}}", + "backRange": "{range}戻る(表示中の期間)", + "forwardRange": "{range}進む(表示中の期間)", + "backDay": "1日戻る", + "forwardDay": "1日進む", + "presetToday": "今日", + "presetDays": "{days}日", + "groupBy": "グループ化", + "interval": "間隔", + "auto": "自動", + "dimensions": { + "account": "アカウント", + "product": "プロダクト", + "country": "国", + "client": "IPハッシュ" + }, + "intervals": { + "minute": "1分", + "hourly": "1時間", + "sixHour": "6時間", + "daily": "1日", + "weekly": "1週間" + }, + "bucketMinutes": "{n}分", + "bucketHours": "{n}時間", + "bucketDays": "{n}日", + "spanHours": "{n, plural, other {#時間}}", + "spanDays": "{n, plural, other {#日}}", + "intervalTooFine": "{interval}間隔は最大{span}の期間に対応しています。この期間は{range}です", + "noTraffic": "{from}から{to}までの期間に、この条件でのトラフィックは記録されていません。", + "coarsened": "{bucket}間隔で表示しています。指定された間隔では、この期間のバーが{max}本を超えるためです。", + "scope": "範囲", + "share": "割合", + "other": "その他" + }, + "AdminBreakdownChart": { + "avgBandwidth": "平均帯域幅", + "bytes": "バイト", + "viewSql": "SQLを表示", + "sqlTitle": "Analytics Engine SQL", + "sqlDescription": "このビューを生成したステートメントを実行順に表示します。", + "fullscreen": "全画面表示", + "exitFullscreen": "全画面表示を終了", + "total": "合計", + "other": "その他", + "bucketUtc": "{date} UTC", + "bucketPlusDays": "{date} + {days}日", + "footnote": "時刻はUTCです。値は推定値で、大量のトラフィックはサンプルから測定しています。バーをクリック(または複数のバーをドラッグ)すると拡大し、ブラウザの戻るボタンで縮小します。" + }, + "AdminCreateDataConnectionPage": { + "metaTitle": "管理者 — データ接続を作成", + "title": "データ接続を作成" + }, + "AdminDataConnectionsPage": { + "metaTitle": "管理者 — データ接続", + "title": "データ接続", + "newConnection": "新しい接続" + }, + "AdminFiltersForm": { + "placeholderAccount": "アカウントID", + "placeholderProduct": "プロダクトID", + "placeholderCountry": "国コード(US)", + "placeholderClient": "IPハッシュの先頭", + "placeholderValue": "値", + "fromAria": "開始(UTC)", + "toAria": "終了(UTC、終了時刻を含まない)", + "utcHint": "UTC · 終了時刻を含まない", + "filterBy": "フィルター", + "filterDimension": "フィルターの項目", + "removeFilter": "フィルターを削除", + "addFilter": "フィルターを追加", + "apply": "適用" + }, + "AdminLayout": { + "title": "管理者" + }, + "AdminTools": { + "analytics": { + "name": "アナリティクス" + }, + "userLookup": { + "name": "ユーザー検索" + }, + "dataConnections": { + "name": "データ接続" + } + }, + "AdminUserLookup": { + "disabled": "無効", + "oryNoIdentity": "Ory にメールアドレス「{query}」の ID はありません。", + "oryNoProfile": "Ory にメールアドレス「{query}」の ID はありますが、source.coop のプロフィールがありません。", + "oryResolved": "メールアドレス「{query}」を Ory で解決しました。", + "databaseNoMatch": "データベース内に「{query}」を含むアカウントハンドルや表示名はありません。", + "databaseMatches": "データベース内で「{query}」を含むハンドルと表示名です。" + }, + "AdminUserLookupPage": { + "metaTitle": "管理者 — ユーザー検索", + "title": "ユーザー検索", + "description": "ユーザー名、名前、メールアドレスでユーザーを探し、プロフィールを開きます。" + }, + "AdminUserSearchField": { + "placeholder": "ユーザー名、名前、または user@example.com", + "ariaLabel": "ユーザーを検索" + }, + "AnalyticsHelp": { + "downloads": "成功したダウンロードの件数です。", + "window": "日付はUTCの日単位です。期間は直近の丸一日分と、今日のこれまでの分を含みます。", + "served": "ダウンロードされたデータの総量です。", + "bandwidth": "この期間にデータがダウンロードされた平均速度です。", + "requests": "データを正常に返したリクエストです。", + "countries": "ダウンロード元の国の数です。", + "dailyAvg": "期間中の1日あたりの平均ダウンロード数です。", + "uniqueIps": "データをダウンロードしたIPアドレスの数です。同じネットワーク(オフィス、大学、クラウドプロバイダーなど)を共有する利用者は1つとして数えられ、アクセスが非常に多いプロダクトではやや少なめに表示される場合があります。", + "uniqueIpsSampled": "この期間に現れたIPアドレスの数です。アクセスの多い期間はサンプルから測定するため、実際より少なく表示されます。期間が広いほど、その差は大きくなります。", + "registered": "ログイン中のユーザーによるダウンロードです。", + "anon": "ログインしていないユーザーによるダウンロードです。", + "distribution": "1回、2回…とダウンロードしたIPアドレスの数です。線は左から右へ累計しており、各バーの位置でその回数以下ダウンロードしたIPアドレスの割合を示します。" + }, + "AnalyticsPanels": { + "downloads": "ダウンロード数", + "dataServed": "配信データ量", + "countries": "国", + "requests": "リクエスト", + "uniqueIps": "ユニークIP", + "registered": "登録ユーザー", + "anonDownloads": "匿名ダウンロード", + "windowDownloads": "{days}日間のダウンロード数", + "dailyDownloadsAria": "過去{days}日間の日別ダウンロード数", + "ipsByDownloadCount": "ダウンロード回数別のIP数", + "noActivity": "この期間にダウンロードはありません。", + "distributionAria": "IPあたりのダウンロード回数別ユニークIP数のパレート図", + "tooltipIps": "{ips} IP · {label}回ダウンロード", + "tooltipShare": "IPの{share}がこの回数以下", + "legendIps": "IP", + "legendCumulative": "累計" + }, + "ApiKeyExpiryField": { + "expires": "有効期限", + "days30": "30日", + "days90": "90日", + "year": "1年", + "never": "無期限 — 失効させるまで" + }, + "ApiKeyList": { + "used": "{when}に使用", + "neverUsed": "未使用", + "revoked": "{when}に{via, select, person {{who} が} owner {設定で} holder {API経由で匿名で} github {GitHub のシークレットスキャンにより} other {}}失効", + "neverExpires": "無期限", + "expires": "{when}に期限切れ", + "expired": "{when}に期限切れ", + "issued": "{when}に {who} が発行", + "lastUsed": "最終使用 {when}", + "actionsFor": "{label} の操作", + "exampleUsage": "使用例", + "changeExpiry": "有効期限を変更", + "revoke": "失効", + "signInWith": "{label} でログイン", + "usageIntro": "APIキーをファイルに保存し、それに合わせて環境変数を設定してください:", + "markerRevoked": "失効済み", + "markerExpired": "期限切れ", + "expiryTitle": "{label} の有効期限はいつにしますか?", + "none": "ありません。" + }, + "AuthButtons": { + "noProfile": "まだプロフィールがありません。プロフィールを完成させるにはこちらをクリックしてください。" + }, + "BreadcrumbNav": { + "root": "ルート" + }, + "CaseStudyCarousel": { + "readCaseStudy": "導入事例を読む →", + "previousQuote": "前の引用", + "nextQuote": "次の引用" + }, + "ChecksumVerifier": { + "notAvailable": "レスポンスヘッダーにチェックサムがありません。", + "failed": "チェックサムを検証できませんでした。", + "verify": "{algorithm}を検証", + "verifying": "検証中…", + "verified": "チェックサムを確認しました", + "mismatch": "チェックサムが一致しません" + }, + "ConditionalGroup": { + "because": "条件:{because}" + }, + "ConnectionUsage": { + "heading": "{count, plural, other {#件のプロダクト}}で使用中", + "unused": "この接続を使用しているものはないため、削除できます。", + "columnProduct": "プロダクト", + "columnRole": "役割", + "columnStatus": "ステータス", + "primary": "プライマリ", + "mirror": "ミラー", + "deactivated": "無効", + "active": "有効" + }, + "ConnectionsEmpty": { + "title": "データ接続はありません" + }, + "CopyToClipboard": { + "copyToClipboard": "クリップボードにコピー" + }, + "CreateServiceAccountPage": { + "metaTitle": "サービスアカウントを作成", + "title": "新しいサービスアカウント", + "description": "誰かのアカウントを共有することなく、付与や取り消しができるソフトウェア用のログインです。" + }, + "DangerZone": { + "title": "危険な操作" + }, + "DataConnectionActions": { + "unauthenticated": "ログインが必要です。", + "invalidFormData": "入力内容が正しくありません。", + "nameTooShort": "IDを作成できるよう、名前には英数字を{min}文字以上含めてください。", + "invalidName": "接続名が正しくありません。", + "unauthorizedCreate": "データ接続を作成する権限がありません。", + "nameTakenField": "その名前はすでに使用されています(ID「{id}」が生成されます)。", + "nameTaken": "その名前のデータ接続はすでに存在します。", + "created": "データ接続を作成しました。", + "createFailed": "データ接続を作成できませんでした。もう一度お試しください。", + "idRequired": "データ接続IDは必須です。", + "notFound": "データ接続が見つかりません。", + "unauthorizedUpdate": "データ接続を更新する権限がありません。", + "deletedConcurrently": "このデータ接続は別の操作によって削除されました。", + "updated": "データ接続を更新しました。", + "updateFailed": "データ接続を更新できませんでした。もう一度お試しください。", + "unauthorizedDelete": "データ接続を削除する権限がありません。", + "inUse": "削除できません:{count}件のプロダクトがまだこの接続を使用しています。先に各プロダクトから外してください。", + "deleted": "データ接続を削除しました。", + "deleteFailed": "データ接続を削除できませんでした。もう一度お試しください。" + }, + "DataConnectionEditPage": { + "metaTitle": "データ接続を編集", + "adminMetaTitle": "管理者 — データ接続を編集", + "back": "データ接続", + "loadingUsage": "プロダクトの使用状況を読み込み中…", + "deleteTitle": "この接続を削除", + "deleteDescription": "接続レコードと保存されている認証情報を削除します。バケットとその中のオブジェクトには影響しません。" + }, + "DataConnectionForm": { + "providerS3Description": "S3 互換のバックエンド(Cloudflare R2、MinIO、Ceph)にも対応します。", + "providerAzureDescription": "ストレージアカウントとそのコンテナの 1 つ。", + "providerGcsDescription": "キー不要:Workload Identity でフェデレーションします。", + "authAccessKey": "アクセスキー", + "authWebIdentityRole": "Web Identity ロール(フェデレーション)", + "authSasToken": "SAS トークン", + "authWorkloadIdentity": "Workload Identity(フェデレーション)", + "authAccessKeyDescription": "お客様が用意する静的な IAM アクセスキーとシークレットです。", + "authWebIdentityRoleDescription": "キー不要:プロキシが Web Identity 経由でお客様の IAM ロールを引き受けます。", + "authSasTokenDescription": "お客様が用意する共有アクセス署名(SAS)トークンです。", + "authAzureWorkloadIdentityDescription": "キー不要:プロキシが Azure AD のアプリ登録にフェデレーションします。", + "authGcpWorkloadIdentityDescription": "キー不要:プロキシが Workload Identity 経由で GCP のサービスアカウントにフェデレーションします。", + "authNoneDescription": "プロダクトのデータを配信する際に、データプロキシがこのバックエンドに認証する方法です。署名なし(公開)アクセスにする場合は「なし」を選択してください。", + "visibility": { + "public": "公開", + "unlisted": "限定公開", + "restricted": "制限付き" + }, + "visibilityDescription": { + "public": "誰でも検索・ダウンロードできるプロダクト。", + "unlisted": "リンクを知っている人だけがアクセスできるプロダクト。", + "restricted": "メンバーに限定されたプロダクト。" + }, + "sectionIdentity": "識別情報", + "nameLabel": "名前", + "nameHelp": "管理者用の一覧と、各プロダクトのストレージ選択に表示されます。", + "idLabel": "ID", + "idHelpEdit": "変更できません。名前を変えても ID は変わりません。", + "idHelpDerived": "名前から生成されます。作成後は変更できません。", + "idHelpEmpty": "名前に英数字をいくつか含めてください。", + "sectionBackend": "バックエンド", + "providerLabel": "プロバイダー", + "providerHelp": "以下に表示される接続設定と認証の項目が決まります。", + "becauseProviderS3": "プロバイダーが AWS S3 の場合", + "becauseProviderGcs": "プロバイダーが Google Cloud の場合", + "becauseProviderAzure": "プロバイダーが Azure Blob の場合", + "bucketLabel": "バケット", + "bucketHelpS3": "データを保存する S3 バケットの名前です。", + "bucketHelpGcs": "Google Cloud Storage バケットの名前です。", + "regionLabel": "リージョン", + "regionHelpS3": "バケットがホストされている AWS リージョンです。Cloudflare R2 などの S3 互換バックエンドでは「auto」を使用してください。", + "regionHelpAzure": "ストレージアカウントがホストされている Azure リージョンです。", + "regionPlaceholder": "リージョンを選択", + "endpointLabel": "エンドポイント", + "endpointHelp": "AWS 以外のバックエンド(Cloudflare R2、MinIO、Ceph)用のカスタム S3 互換エンドポイントです。AWS S3 の場合は空欄にしてください。", + "accountNameLabel": "アカウント名", + "accountNameHelp": "Azure Storage のアカウント名です。", + "containerNameLabel": "コンテナ名", + "containerNameHelp": "Azure Blob Storage のコンテナ名です。", + "sectionAuthentication": "認証", + "authTypeLabel": "認証方式", + "authTypeHelp": "選択できる方式は、上で選んだバックエンドによって異なります。", + "authNone": "なし(署名なし)", + "becauseMethodAccessKey": "認証方式がアクセスキーの場合", + "becauseMethodSasToken": "認証方式が SAS トークンの場合", + "becauseMethodWebIdentityRole": "認証方式が Web Identity ロールの場合", + "becauseMethodWorkloadIdentity": "認証方式が Workload Identity の場合", + "accessKeyIdLabel": "アクセスキー ID", + "accessKeyIdHelp": "使用中の認証情報を識別します。秘密情報ではありません。表示されることがないのは、対になるシークレットアクセスキーの方です。", + "secretAccessKeyLabel": "シークレットアクセスキー", + "secretAccessKeyHelp": "アクセスキー ID と対になるキーです。暗号化して保存され、再表示されることはありません。", + "sasTokenLabel": "SAS トークン", + "sasTokenHelp": "コンテナへのアクセスを許可する共有アクセス署名です。暗号化して保存され、再表示されることはありません。", + "roleArnLabel": "ロール ARN", + "roleArnHelp": "プロキシが AssumeRoleWithWebIdentity(キー不要のフェデレーション)で引き受ける IAM ロールです。ARN であり、秘密情報ではありません。", + "trustSubjectLabel": "信頼ポリシーのサブジェクト", + "trustSubjectHelp": "プロキシはロールを引き受ける際にこの OIDC サブジェクトを提示します。ロールの信頼ポリシーで、data.source.coop:aud = sts.amazonaws.com とあわせて、data.source.coop:sub がこの値に一致する StringLike 条件を追加してください。", + "tenantIdLabel": "テナント ID", + "tenantIdHelp": "Workload Identity フェデレーションに使用する Azure AD のテナント(ディレクトリ)ID です。", + "clientIdLabel": "クライアント ID", + "clientIdHelp": "フェデレーション ID 資格情報を持つアプリ登録の(クライアント)ID です。", + "workloadIdentityProviderLabel": "Workload Identity プロバイダー", + "workloadIdentityProviderHelp": "GCP Workload Identity プロバイダーの完全なリソース名です。秘密情報ではありません。", + "serviceAccountLabel": "サービスアカウント", + "serviceAccountHelp": "プロキシが成り代わる GCP サービスアカウントのメールアドレスです。秘密情報ではありません。", + "sectionKeyLayout": "キーの配置", + "basePrefixLabel": "ベースプレフィックス", + "basePrefixHelp": "バケットまたはコンテナ内の共通ルート(任意)です。この接続のすべてのプロダクトがこの下に配置されます。ルートを使う場合は空欄にしてください。", + "prefixTemplateLabel": "プレフィックステンプレート", + "prefixTemplateHelp": "バケットまたはコンテナ内で、各プロダクトのオブジェクトが配置される場所です。プロダクトがこの接続を追加すると、{accountIdVar} と {repositoryIdVar} が置き換えられます。", + "examplePrefixLabel": "プレフィックスの例", + "examplePrefixHelp": "{path} のプロダクトが保存される場所です。", + "sectionPolicy": "ポリシー", + "readOnlyLabel": "読み取り専用", + "readOnlyHelp": "プロダクトは閲覧とダウンロードのみ可能で、書き込みはできません。署名なしの接続では必須です。", + "allowedVisibilitiesLabel": "許可する公開範囲", + "allowedVisibilitiesHelp": "この接続のプロダクトが使用できる公開範囲です。プロダクトの作成時と、公開範囲の変更時に確認されます。", + "requiredFlagLabel": "必須フラグ", + "requiredFlagHelp": "この接続をプロダクトに使用するために、オーナーが持っている必要があるアカウントフラグです。制限しない場合は「なし」を選択してください。" + }, + "DataConnectionsList": { + "systemOwner": "システム", + "emptyHint": "データ接続を作成して始めましょう。", + "readOnly": "読み取り専用", + "permitsNothing": "許可なし", + "listSeparator": "、", + "visibility": { + "public": "公開", + "unlisted": "限定公開", + "restricted": "制限付き" + } + }, + "DeleteConnectionNote": { + "blocked": "削除できません:{count, plural, other {#件のプロダクト}}がまだこの接続を使用しています。先に各プロダクトから外してください。" + }, + "DeleteDataConnectionButton": { + "trigger": "接続を削除", + "confirmTitle": "この接続を削除しますか?", + "confirmDescription": "接続レコードと保存されている認証情報を削除します。バケットとその中のオブジェクトには影響しません。この操作は取り消せません。" + }, + "DeleteProductModal": { + "deleteFailed": "プロダクトを削除できませんでした。", + "deleteProduct": "プロダクトを削除", + "readOnlyTooltip": "この接続は読み取り専用のため、Source が基になるデータを削除することはありません。", + "recordOnlyLabel": "プロダクトのレコードのみ削除", + "recordOnlyReadOnly": "この接続は読み取り専用のため、基になるデータは常に保持されます。", + "recordOnlyDescription": "プロダクトとそのメンバーシップを削除し、接続内の基になるデータは保持します。", + "confirmPrompt": "確認のため、下に {expected} と入力してください:", + "consequencesHeading": "このプロダクトを削除すると、次のようになります:", + "consequenceRecord": "プロダクトのレコードとページを削除", + "consequenceMemberships": "すべてのメンバーシップを削除", + "consequenceKeepData": "接続内の基になるデータは保持", + "consequenceDeleteData": "接続内の基になるデータをすべて削除", + "cannotBeUndone": "この操作は元に戻せません。" + }, + "DeleteProgress": { + "dismiss": "閉じる", + "deleted": "{count, plural, other {{shown}件のファイルを削除済み}}", + "counting": "ファイルを集計中… {found}件", + "deletedOfTotal": "{count, plural, other {{total}件中{deleted}件のファイルを削除済み}}" + }, + "DirectoryList": { + "empty": "このディレクトリは空です。", + "scrollForMore": "スクロールして続きを表示" + }, + "DirectoryRow": { + "retryUpload": "アップロードを再試行", + "cancelUpload": "アップロードをキャンセル", + "download": "{name}をダウンロード", + "copyUrl": "URLをクリップボードにコピー", + "deleteFailed": "削除に失敗しました", + "deleteItem": "{name}を削除", + "deleteFolderTitle": "フォルダを削除", + "deleteFileTitle": "ファイルを削除", + "deleteFolderConfirm": "{name} とその中身をすべて削除してもよろしいですか?この操作は元に戻せません。", + "deleteFileConfirm": "{name} を削除してもよろしいですか?この操作は元に戻せません。", + "queued": "待機中…", + "uploading": "アップロード中 {percent}%", + "uploadError": "エラー:{error}", + "cancelled": "キャンセル済み" + }, + "Dropzone": { + "dropToUpload": "ここにファイルをドロップしてアップロード" + }, + "EditAccountLayout": { + "details": "詳細", + "profilePicture": "プロフィール画像", + "dataConnections": "データ接続", + "permissions": "権限", + "serviceAccounts": "サービスアカウント", + "memberships": "メンバーシップ", + "authentication": "認証", + "authenticationDisabledTooltip": "管理者は他のユーザーの認証情報を編集できません。", + "viewProfile": "プロフィールを表示" + }, + "EditAccountMembershipsPage": { + "metaTitle": "{name}のメンバーシップを編集", + "title": "メンバーシップ", + "description": "組織のメンバーとそのロールを管理します", + "emptyTitle": "メンバーはまだいません", + "emptyDescription": "組織にメンバーを招待しましょう" + }, + "EditAccountPermissionsPage": { + "metaTitle": "{name}の権限を編集", + "title": "権限", + "description": "このアカウントの権限と機能を管理します" + }, + "EditAccountProfilePage": { + "metaTitle": "{name}のプロフィールを編集", + "title": "公開プロフィール", + "descriptionIndividual": "公開プロフィール情報を更新します", + "descriptionOrganization": "組織の公開プロフィール情報を更新します" + }, + "EditAccountProfilePicturePage": { + "metaTitle": "{name}の設定", + "title": "プロフィール画像", + "descriptionIndividual": "アカウントのプロフィール画像を管理します", + "descriptionOrganization": "組織のプロフィール画像を管理します" + }, + "EditProductDetailsPage": { + "metaTitle": "{title}の詳細を編集", + "deleteTitle": "このプロダクトを削除", + "deleteDescription": "元に戻せません。プロダクトのレコード、そのメンバーシップ、アクセス記録を削除します。基盤となるオブジェクトも削除されるかどうかは、データ接続によって異なります。" + }, + "EditProductLayout": { + "details": "詳細", + "memberships": "メンバーシップ", + "dataConnections": "データ接続", + "viewProduct": "プロダクトを表示" + }, + "EditProductMembershipsPage": { + "metaTitle": "{title}のメンバーシップを編集", + "title": "メンバーシップ", + "description": "プロダクトのメンバーとそのロールを管理します", + "productMembers": "プロダクトのメンバー", + "productMembersDescription": "以下のユーザーには、このプロダクトへのアクセスが明示的に付与されています。", + "productMembersEmptyTitle": "メンバーはまだいません", + "productMembersEmptyDescription": "プロダクトにメンバーを招待しましょう", + "organizationMembers": "組織のメンバー", + "organizationMembersDescription": "以下のユーザーは、組織のメンバーシップを通じてこのプロダクトに暗黙的にアクセスできます。", + "organizationMembersEmptyTitle": "組織のメンバーはいません", + "organizationMembersEmptyDescription": "この組織にはメンバーがいません" + }, + "EditProductNotFound": { + "metaTitle": "プロダクトが見つかりません | Source Cooperative", + "metaDescription": "指定されたプロダクトは見つかりませんでした。", + "notFound": "見つかりません" + }, + "EditProfileForm": { + "sectionIdentity": "基本情報", + "sectionAbout": "紹介", + "sectionIdentifiers": "識別子", + "sectionLinks": "リンク", + "name": "名前", + "namePlaceholder": "あなたの名前", + "nameDescription": "プロフィールに表示される名前です", + "email": "メールアドレス", + "emailDescriptionIndividual": "主要なメールアドレスです。アカウント設定で変更できます。", + "emailDescriptionOrganization": "組織の連絡先メールアドレス", + "bio": "自己紹介", + "description": "説明", + "bioPlaceholder": "あなたについて教えてください", + "bioDescription": "あなた自身やあなたの活動の簡単な説明", + "descriptionPlaceholder": "組織について教えてください", + "descriptionDescription": "組織の簡単な説明", + "orcid": "ORCID ID", + "orcidDescription": "ORCID識別子(任意)", + "rorId": "ROR ID", + "rorIdDescription": "Research Organization Registry識別子(任意)", + "websites": "ウェブサイト", + "websitesDescription": "プロフィールに関連するウェブサイトを追加", + "addWebsite": "ウェブサイトを追加", + "removeWebsite": "ウェブサイトを削除" + }, + "EmailVerificationCallout": { + "unverified": "メールをご確認ください。メールアドレスを確認するためのコードを送信しました。または、こちらをクリックしてメールアドレスを確認してください。", + "justVerified": "メールアドレスの確認が完了しました。アカウントの確認にご協力いただきありがとうございます。" + }, + "EmailVerificationStatus": { + "verifiedOn": "{date}にメールアドレスを確認済み", + "notVerified": "メールアドレス未確認" + }, + "ExampleUsage": { + "githubWorkflowIntro": "このワークフローをカスタマイズし、.github/workflows/ 以下のファイルに保存してください:" + }, + "FetchCredentialsButton": { + "editMode": "編集モード", + "readOnly": "読み取り専用", + "viewCredentials": "認証情報を表示", + "uploadFiles": "ファイルをアップロード", + "uploadDirectory": "フォルダをアップロード" + }, + "Field": { + "required": "(必須)" + }, "Footer": { "aboutUs": "概要", "docs": "ドキュメント", @@ -29,11 +629,732 @@ "allProducts": "すべてのプロダクト", "attribution": "Source Cooperative は Radiant Earth のプロジェクトです" }, + "GithubWorkflowFields": { + "repository": "リポジトリ", + "repositoryHelp": "owner/repo、または永続的な ID を含めた owner@123/repo@456 の形式です。不変のサブジェクトクレームについて", + "confirmed": "GitHub で確認済み。サブジェクトクレームのプレフィックスは repo:{repository} です", + "customized": "このリポジトリはサブジェクトクレームをカスタマイズしているため、そのトークンには以下のサブジェクトが含まれません。信頼できるのは GitHub のデフォルト形式のサブジェクトのみです。管理者はリポジトリのOIDC settingsで Use default template を選択すると元に戻せます。", + "notVisible": "{repository} を参照できません。非公開か、存在しないリポジトリです。存在を確認してから、デフォルトのサブジェクトクレームのプレフィックスをリポジトリとして入力してください。確認方法:", + "unreachable": "現在 GitHub で {repository} を確認できませんでした。問い合わせ回数の制限が原因と思われます。数分後に再試行するか、デフォルトのサブジェクトクレームのプレフィックスをリポジトリとして入力してください。確認方法:", + "findAsAdmin": "リポジトリの管理者として、OIDC settingsから Default subject claim prefix をコピーします。", + "findWithCli": "または、GitHub CLI で次を実行します:", + "allowRunsFrom": "実行を許可する対象", + "allowRunsFromHelp": "ジョブのサブジェクトクレームには、ワークフロー内で環境が指定されていればその環境が、そうでなければブランチまたはタグが含まれます。プルリクエストの実行にはどちらも含まれないため、ログインできません。GitHub の例を見る", + "kindBranch": "ブランチ", + "kindTag": "タグ", + "kindEnvironment": "環境", + "trustsTokensMatching": "次に一致するトークンを信頼" + }, + "GlobalUploadNotification": { + "title": "アップロード({count, number})", + "uploadingCount": "{count, number}件アップロード中", + "queuedCount": "{count, number}件待機中", + "all": "すべて({count, number})", + "status": "{status, select, queued {待機中} uploading {アップロード中} completed {完了} error {エラー} cancelled {キャンセル済み} other {{status}}}", + "viewFile": "ファイルを表示", + "viewDirectory": "フォルダを表示", + "clearCompleted": "完了をクリア", + "clearErrors": "エラーをクリア", + "clearAll": "すべてクリア" + }, + "GrantProductDialog": { + "grantProduct": "プロダクトを付与", + "product": "プロダクト", + "productPlaceholder": "プロダクトを選択", + "access": "アクセス", + "read": "読み取り", + "readWrite": "読み取りと書き込み", + "grant": "付与" + }, + "HeadingWithPermalink": { + "permalink": "パーマリンク" + }, + "HeroGlobe": { + "alt": "世界中のリアルタイムのデータリクエストの可視化" + }, + "IndividualProfile": { + "websites": "{count, plural, other {ウェブサイト}}", + "organizations": "組織", + "products": "プロダクト", + "newProduct": "新しいプロダクト", + "noProducts": "プロダクトはありません。", + "contributions": "コントリビューション" + }, + "InviteMemberForm": { + "account": "アカウント", + "accountDescription": "ユーザー名または名前で検索するか、アカウントIDを入力してください。組織のサービスアカウントも候補に表示されます。", + "accountPlaceholder": "ユーザー名または名前", + "role": "ロール", + "rolePlaceholder": "ロールを選択", + "roleDescription": "新しいメンバーに割り当てるロール", + "roleReader": "読み取り", + "roleWriter": "書き込み", + "roleMaintainer": "メンテナー", + "roleOwner": "オーナー", + "invite": "メンバーを招待", + "title": "新しいメンバーを招待", + "description": "ユーザーを{name}のメンバーとして招待します。", + "submit": "招待を送信" + }, + "IssueApiKeyDialog": { + "title": "APIキーを発行", + "done": "完了", + "description": "OIDC が使えない環境(サーバー、スケジューラー、観測機器など)向けです。このキーは、このサービスアカウントとして、付与された権限のとおりにログインします。", + "label": "ラベル", + "labelHelp": "このキーの使用場所です。どのキーを失効させるべきか判断できるようにします。", + "labelPlaceholder": "HPC の cron ジョブ", + "addKey": "キーを追加", + "issueKey": "キーを発行" + }, + "IssuedApiKey": { + "copyNow": "今すぐキーをコピーしてください。キー全体が表示されるのはこの一度だけです。", + "hideKey": "キーを隠す", + "showKey": "キーを表示" + }, + "IssuedApiKeyDialog": { + "title": "APIキーを発行しました", + "done": "完了" + }, + "LandingPage": { + "heroTitle": "協同で運営するデータ公開ユーティリティ", + "heroTagline": "あらゆる規模のデータを、誰もが公開できます。", + "heroBody": "独自のインフラを構築・運用することなく、データをアップロード、共有、利用できます。", + "readDocs": "ドキュメントを読む →", + "exploreProducts": "データプロダクトを探す →", + "featuredEyebrow": "Sourceのデータセットを探す", + "featuredHeading": "注目のプロダクト", + "whatIsEyebrow": "Source Cooperativeとは?", + "challengeHeading": "課題", + "challenge1": "科学データのインフラは、国境や分野を越えた協力を前提に作られていません。", + "challenge2": "地球規模の課題に取り組むには、政府、研究機関、商用プロバイダー、市民社会のデータを組み合わせる必要がありますが、それぞれが独自の形式、アクセス方法、APIを持っています。", + "challenge3": "データエンジニア、科学者、研究者は本来の仕事よりもインフラへの対応に多くの時間を費やしており、それが協力を妨げ、情報に基づく意思決定を制限しています。", + "solutionHeading": "私たちのソリューション", + "solution1": "Sourceは、汎用的なクラウドオブジェクトストレージ上に構築された非営利のデータ公開ユーティリティです。研究者はサーバーの運用、ポータルの構築、APIの開発なしに、あらゆる規模のデータを公開できます。", + "solution2": "Sourceがクラウドインフラを担うため、研究者はホスティングではなくデータに集中できます。ユーザーは既存のツールで使える標準的なURLを通じてすべてにアクセスでき、独自のポータルや専用のAPIは必要ありません。", + "solution3": "このサービスは意図的にコモディティ化されています。ロックインを避けるよう作られており、特定のプロバイダー、資金提供者、政治的決定によって研究データが失われることのないよう設計されています。", + "whyEyebrow": "Sourceが違う理由", + "whyHeading": "誰もが使えるデータ公開", + "iconAlt": "アイコン", + "integrateHeading": "あらゆるワークフローに統合", + "integrateBody": "Sourceは、S3互換のあらゆるクラウドプロバイダーで動作する、ベンダー中立のデータ公開ユーティリティです。", + "neutralHeading": "中立的なホスティング、ミッション主導の運営", + "neutralBody": "データの所有権と管理権を完全に保持できます。Sourceはオープンフォーマットをサポートする非商用リポジトリなので、独自のAPIやワークフローに縛られることはありません。", + "pricingHeading": "定額料金", + "pricing1": "データポータルの構築・維持や変動するクラウドコストへの対応に追われることなく、データを公開しやすく使いやすくするための基本に集中できます。", + "pricing2": "データ量と利用量に基づく料金プランは近日発表予定です。", + "caseStudiesEyebrow": "導入事例", + "caseStudiesHeading": "オープンソースで、ガバナンスのもとに運営され、これらのチームに信頼されています", + "builtForHeading": "データエンジニア、科学者、開発者のために", + "builtForBody": "Source Cooperativeは、企業のクラウドポリシーに煩わされることなく、プロトタイプから一般公開までデータのライフサイクルを支えます。", + "exploreData": "データを探す →" + }, + "LiveGlobe": { + "aria": "ライブのデータリクエストの発生地点を示すインタラクティブな3D地球儀", + "requests": "{count, plural, other {#件のリクエスト}}" + }, "LocaleSwitcher": { "label": "言語" }, + "LoginButton": { + "logInOrRegister": "ログイン / 新規登録" + }, + "LoginRequired": { + "signIn": "ログイン" + }, + "MembershipActions": { + "unauthenticated": "ログインが必要です。", + "missingFields": "必須項目が入力されていません。", + "organizationNotFound": "組織が見つかりません。", + "inviteeNotFound": "招待するアカウントが見つかりません。", + "organizationsCannotBeMembers": "組織はメンバーになれません。", + "unauthorizedInvite": "この組織にメンバーを招待する権限がありません。", + "alreadyMember": "このユーザーはすでにメンバーであるか、保留中の招待があります。", + "invited": "メンバーを招待しました。", + "inviteFailed": "メンバーを招待できませんでした。もう一度お試しください。", + "membershipNotFound": "メンバーシップが見つかりません。", + "unauthorizedAccept": "この招待を承諾する権限がありません。", + "notPending": "このメンバーシップは保留中ではありません。", + "acceptFailed": "招待を承諾できませんでした。", + "unauthorizedReject": "この招待を辞退する権限がありません。", + "rejectFailed": "招待を辞退できませんでした。", + "missingMembershipId": "メンバーシップIDが指定されていません。", + "unauthorizedRevoke": "このメンバーシップを取り消す権限がありません。", + "revoked": "メンバーシップを取り消しました。", + "revokeFailed": "メンバーシップを取り消せませんでした。もう一度お試しください。" + }, + "MembershipsTable": { + "member": "メンバー", + "role": "ロール", + "status": "ステータス", + "lastUpdated": "最終更新", + "actions": "操作", + "serviceAccount": "サービスアカウント", + "roleOwner": "オーナー", + "roleMaintainer": "メンテナー", + "roleWriter": "書き込み", + "roleReader": "読み取り", + "stateMember": "メンバー", + "stateInvited": "招待中", + "stateRevoked": "取り消し済み", + "unknown": "不明", + "revoke": "取り消す" + }, + "MobileMenu": { + "openMenu": "メニューを開く", + "closeMenu": "メニューを閉じる", + "menu": "メニュー", + "products": "プロダクト", + "docs": "ドキュメント", + "you": "あなた", + "viewProfile": "プロフィールを表示", + "viewOrganization": "組織を表示", + "noProducts": "プロダクトはまだありません", + "newProduct": "新規プロダクト", + "newOrganization": "新規組織", + "invitations": "招待", + "noInvitations": "保留中の招待はありません", + "admin": "管理者", + "logout": "ログアウト" + }, "NavLinks": { "products": "プロダクト", "docs": "ドキュメント" + }, + "NewOrganizationPage": { + "accessDenied": "アクセスが拒否されました", + "loginRequired": "組織を作成するにはログインする必要があります。", + "title": "新しい組織を作成", + "description": "新しい組織を作成して、他のユーザーと共同作業できます" + }, + "NewProductPage": { + "accessDenied": "アクセスが拒否されました", + "mustLogIn": "プロダクトを作成するにはログインが必要です。", + "noPermission": "プロダクトを作成する権限がありません。", + "contactUs": "誤りと思われる場合は、{email} までお問い合わせください。", + "title": "新しいプロダクトを作成", + "description": "他のユーザーと共有する新しいプロダクトを作成します" + }, + "NotFoundMetadata": { + "title": "ページが見つかりません", + "description": "お探しのページは存在しません。" + }, + "ObjectPreviewExternal": { + "noPreview": "ファイル形式 .{extension} のプレビューはありません。この形式への対応をご希望の場合は、Issue を作成してください。", + "previewTitle": "{path}のプレビュー" + }, + "ObjectPreviewSlot": { + "title": "オブジェクトのプレビュー", + "openInNewTab": "新しいタブで開く", + "iframeTitle": "{path}のプレビュー" + }, + "ObjectSummary": { + "name": "名前", + "size": "サイズ", + "contentType": "コンテンツタイプ", + "lastModified": "最終更新日時", + "checksum": "チェックサム", + "sourceUrl": "Source URL", + "cloudUri": "クラウドURI", + "download": "ダウンロード" + }, + "OnboardingForm": { + "username": "ユーザー名", + "usernameDescription": "プロフィールのURLになります:source.coop/[username]", + "usernamePlaceholder": "ユーザー名を選択", + "checking": "ユーザー名が使用可能か確認しています…", + "available": "✓ このユーザー名は使用できます", + "fullName": "氏名", + "fullNameDescription": "プロフィールに表示される名前です", + "fullNamePlaceholder": "あなたの名前", + "submit": "プロフィールを完成" + }, + "OnboardingPage": { + "title": "プロフィールを完成", + "metaDescription": "ユーザー名を選んでプロフィールを設定します", + "description": "あと少しで完了です。アカウントのユーザー名を選び、お名前を入力してください。" + }, + "OrganizationCreationForm": { + "name": "組織名", + "nameDescription": "組織の名前", + "namePlaceholder": "組織名を入力", + "accountId": "アカウントID", + "accountIdDescription": "組織のアカウントID", + "accountIdPlaceholder": "アカウントIDを入力", + "checking": "アカウントIDが使用可能か確認しています…", + "available": "✓ 使用できます", + "description": "説明", + "descriptionDescription": "組織の簡単な説明", + "descriptionPlaceholder": "組織について説明してください", + "website": "ウェブサイト", + "websiteDescription": "組織のウェブサイト(任意)", + "email": "メールアドレス", + "emailDescription": "組織の連絡先メールアドレス(任意)", + "submit": "作成" + }, + "OrganizationMembers": { + "empty": "表示するメンバーはいません", + "owners": "オーナー", + "admins": "管理者", + "members": "メンバー" + }, + "OrganizationProfile": { + "details": "組織の詳細", + "emailLabel": "メールアドレス:", + "rorIdLabel": "ROR ID:", + "members": "メンバー", + "products": "プロダクト", + "newProduct": "新しいプロダクト", + "noProducts": "プロダクトはありません。" + }, + "PendingInvitationBanner": { + "acceptFailed": "招待を承諾できませんでした", + "acceptError": "招待の承諾中にエラーが発生しました", + "rejectFailed": "招待を辞退できませんでした", + "rejectError": "招待の辞退中にエラーが発生しました", + "roleOwners": "オーナー", + "roleMaintainers": "メンテナー", + "roleReadData": "データ読み取り", + "roleWriteData": "データ書き込み", + "message": "この{invitationType, select, product {プロダクト} other {組織}}に{roleName}として参加するよう招待されています。", + "processing": "処理中…", + "accept": "承諾", + "decline": "辞退" + }, + "PreviewIframe": { + "unsupported": "お使いのブラウザは iframe に対応していません。" + }, + "ProductAccessList": { + "noProducts": "{owner} にはまだプロダクトがありません。", + "empty": "まだありません。プロダクトを付与すると、データの読み取りや書き込みができるようになります。", + "opensInNewTab": "新しいタブで開きます", + "accessTo": "{product} へのアクセス", + "removeAccess": "{product} へのアクセスを削除", + "read": "読み取り", + "readWrite": "読み取りと書き込み" + }, + "ProductActions": { + "unauthenticated": "ログインが必要です。", + "invalidFormData": "入力内容が正しくありません。", + "unauthorizedCreate": "プロダクトを作成する権限がありません。", + "connectionRequired": "データ接続は必須です。", + "connectionNotFoundField": "選択したデータ接続が見つかりません。", + "invalidConnection": "データ接続が正しくありません。", + "connectionNotPermitted": "選択したデータ接続を使用する権限がありません。", + "connectionWrongAccountField": "選択したデータ接続はこのアカウントでは利用できません。", + "connectionWrongAccount": "このアカウントでは使用できないデータ接続です。", + "visibilityNotAllowed": "データ接続「{name}」では{visibility, select, public {公開} unlisted {限定公開} restricted {制限付き} other {{visibility}}}のプロダクトは許可されていません。", + "invalidVisibility": "選択したデータ接続では、この公開範囲を使用できません。", + "idsRequired": "アカウントIDとプロダクトIDは必須です。", + "productNotFound": "プロダクトが見つかりません。", + "unauthorizedUpdate": "このプロダクトを更新する権限がありません。", + "connectionMissingField": "このプロダクトのデータ接続が見つからないため、公開範囲を変更できません。", + "connectionMissing": "このプロダクトのデータ接続が見つかりません。", + "invalidVisibilityForProduct": "このプロダクトのデータ接続では、この公開範囲を使用できません。", + "updated": "プロダクトを更新しました。", + "updateFailed": "プロダクトを更新できませんでした。もう一度お試しください。", + "unauthorizedDelete": "このプロダクトを削除する権限がありません。", + "cannotPreserveData": "このプロダクトを削除する際にデータを残す権限がありません。", + "deleteFailed": "プロダクトを削除できませんでした:{reason}" + }, + "ProductAnalyticsPage": { + "metaTitle": "{title} — アナリティクス", + "heading": "アナリティクス", + "previewNotice": "アナリティクスはプレビュー機能です。ここに表示される指標や閲覧できるユーザーは、近いうちに変更される可能性があります。ご意見はhello@source.coopまでお寄せください。", + "windowDays": "{days}日", + "unavailable": "現在アナリティクスを利用できません。数分後にもう一度お試しください。" + }, + "ProductAnalyticsView": { + "downloadsTab": "ダウンロード", + "usersTab": "ユーザー", + "dailyAvg": "1日平均", + "byCountry": "国別", + "breakdownUnavailable": "国別の内訳を表示できません。", + "otherCountries": "その他{count}か国", + "unknownCountry": "不明", + "topFiles": "上位ファイル", + "noFileDownloads": "この期間にファイルのダウンロードはありません。", + "file": "ファイル" + }, + "ProductContentsCard": { + "contents": "コンテンツ" + }, + "ProductCreationForm": { + "visibility": { + "public": { + "label": "公開", + "description": "誰でも検索・ダウンロードできます。検索結果とプロダクトフィードに表示されます。" + }, + "unlisted": { + "label": "限定公開", + "description": "リンクを知っている人なら誰でもダウンロードできます。検索結果とフィードには表示されません。" + }, + "restricted": { + "label": "制限付き", + "description": "このプロダクトのメンバーのみ。" + } + }, + "noVisibilities": "公開範囲なし", + "connectionOption": "{name}{location} · {visibilities}", + "connectionOptionReadOnly": "{name}{location} · {visibilities} · 読み取り専用", + "sectionDescription": "説明", + "sectionStorage": "ストレージ", + "sectionAccess": "アクセス", + "titleLabel": "プロダクト名", + "titleDescription": "プロダクトの名前", + "titlePlaceholder": "プロダクト名を入力", + "ownerLabel": "オーナーアカウント", + "ownerDescription": "プロダクトを所有するアカウント", + "productIdLabel": "プロダクトID", + "productIdDescription": "プロダクトのID", + "productIdPlaceholder": "プロダクトIDを入力", + "checkingAvailability": "{id} が使用可能か確認中", + "available": "✓ 使用可能:{id}", + "descriptionLabel": "説明", + "descriptionDescription": "プロダクトの簡単な説明", + "descriptionPlaceholder": "プロダクトについて説明してください", + "connectionLabel": "データ接続", + "connectionDescription": "このプロダクトのデータの保存先です。選択できるリージョンと公開範囲が決まります。迷った場合は us-west-2 の接続を選んでください。", + "noConnections": "利用できるデータ接続がありません", + "visibilityLabel": "公開範囲", + "visibilityConnectionMissing": "このプロダクトのデータ接続が見つからないため、公開範囲を変更できません。", + "visibilityDescription": "このプロダクトにアクセスできる人です。選択肢はデータ接続によって異なります。", + "statusLabel": "ステータス", + "statusActive": "有効", + "statusDeactivated": "無効", + "statusDescription": "無効にすると、プロダクトは source.coop に表示されなくなり、data.source.coop API からもアクセスできなくなります。データは保持されますが、再度有効にできるのは Source Cooperative の管理者のみです。" + }, + "ProductDataUnavailable": { + "accessUnconfirmed": "この非公開プロダクトのデータへのアクセス権を現在確認できません。", + "errorDetails": "エラーの詳細(メンテナーのみ表示)", + "tryAgain": "再試行" + }, + "ProductErrorPage": { + "heading": "プロダクトの読み込み中に問題が発生しました", + "body": "現在このプロダクトのコンテンツを読み込めません。通常は一時的な問題です。しばらくしてからもう一度お試しください。", + "reference": "参照番号:{digest}", + "tryAgain": "再試行" + }, + "ProductLayout": { + "deactivatedNotice": "このプロダクトは無効化されています。オーナーと管理者以外には表示されません。" + }, + "ProductListItem": { + "providedBy": "提供:", + "publishedOn": "公開日:", + "public": "公開", + "unlisted": "限定公開", + "restricted": "制限付き", + "publicProduct": "公開プロダクト", + "unlistedProduct": "限定公開プロダクト", + "restrictedProduct": "制限付きプロダクト", + "deactivated": "無効", + "deactivatedProduct": "無効化されたプロダクト" + }, + "ProductLoading": { + "contents": "コンテンツ", + "details": "詳細" + }, + "ProductMetaCard": { + "details": "詳細" + }, + "ProductMetaContent": { + "visibility": "公開範囲", + "public": "公開", + "unlisted": "限定公開", + "restricted": "制限付き", + "owner": "オーナー", + "created": "作成日", + "lastUpdated": "最終更新日" + }, + "ProductMetadata": { + "unknownAccount": "不明なアカウント", + "description": "{account}によるデータプロダクト" + }, + "ProductMirrorActions": { + "notAccountManager": "このプロダクトのデータ接続を管理できるのは、所有アカウントのオーナーとメンテナーのみです。", + "concurrentEdit": "このプロダクトは他のユーザーによって変更されました。再読み込みしてもう一度お試しください。", + "unauthenticated": "ログインが必要です。", + "missingFields": "必須項目が入力されていません。", + "productNotFound": "プロダクトが見つかりません。", + "connectionNotFound": "データ接続が見つかりません。", + "connectionUnavailable": "このデータ接続はこのアカウントでは利用できません。", + "alreadyAssociated": "このデータ接続はすでにこのプロダクトに関連付けられています。", + "added": "データ接続を追加しました。", + "addFailed": "データ接続を追加できませんでした。もう一度お試しください。", + "mirrorNotFound": "ミラーが見つかりません。", + "removed": "データ接続を外しました。", + "removeFailed": "データ接続を外せませんでした。もう一度お試しください。", + "invalidPrefix": "プレフィックスは「/」で始めることも、「..」を含めることもできません。", + "cannotEditPrefix": "プレフィックスを編集するには、このアカウントとデータ接続の両方を管理できる必要があります。", + "prefixUpdated": "プレフィックスを更新しました。", + "prefixUpdateFailed": "プレフィックスを更新できませんでした。もう一度お試しください。", + "primaryUpdated": "プライマリミラーを更新しました。", + "primaryUpdateFailed": "プライマリミラーを更新できませんでした。もう一度お試しください。" + }, + "ProductMirrorsManager": { + "title": "データ接続", + "description": "このプロダクトのオブジェクトの保存先です。訪問者はプライマリ接続からダウンロードします。", + "readOnlyNotice": "このプロダクトのデータ接続を変更できるのは、{account} のオーナーとメンテナーのみです。", + "cacheNotice": "データプロキシはこれらの設定をキャッシュします。ここでの変更が反映されるまで最大 5 分かかる場合があります。", + "emptyManage": "このプロダクトにデータ接続を追加してください。", + "emptyReadOnly": "このプロダクトにはデータ接続が設定されていません。", + "primary": "プライマリ", + "actionsFor": "{name} の操作", + "makePrimary": "プライマリにする", + "editConnection": "接続を編集", + "removeFromProduct": "プロダクトから外す…", + "prefixLabel": "プレフィックス", + "prefixHelp": "接続内でこのプロダクトのオブジェクトが置かれる場所です。このプロダクトのアカウントと接続の両方を管理しているため、変更できます。", + "connectionRoot": "(接続のルート)", + "addTitle": "接続を追加", + "noneAvailable": "このアカウントで利用できる接続はほかにありません。", + "allAttached": "このアカウントで利用できる接続はすべて追加済みです。", + "choosePlaceholder": "接続を選択…", + "add": "追加", + "removeConfirmTitle": "{name} を外しますか?", + "removeConfirmDescription": "このプロダクトはそのストレージから配信されなくなります。オブジェクト自体は削除されず、後で接続を再度追加できます。" + }, + "ProductNotFoundPage": { + "metaTitle": "プロダクトが見つかりません | Source Cooperative", + "metaDescription": "指定されたプロダクトは見つかりませんでした。", + "title": "見つかりません" + }, + "ProductPage": { + "dataUnavailable": "現在このプロダクトのファイルを読み込めません。ストレージバックエンドから応答がありませんでした。しばらくしてからもう一度お試しください。" + }, + "ProductSearchResult": { + "updated": "更新日:", + "public": "公開", + "private": "非公開" + }, + "ProductTabs": { + "product": "プロダクト", + "analytics": "アナリティクス", + "restrictedTooltip": "このプロダクトのオーナー、メンテナー、サイト管理者のみに表示されます。", + "restricted": "制限付き" + }, + "ProductsFilters": { + "searchPlaceholder": "プロダクトを検索…", + "tagsPlaceholder": "タグで絞り込み(カンマ区切り)", + "featured": "注目", + "clearFilters": "フィルターをクリア" + }, + "ProductsList": { + "empty": "条件に一致するプロダクトが見つかりません。", + "navLabel": "プロダクト一覧" + }, + "ProductsPage": { + "metaTitle": "プロダクト | Source Cooperative", + "metaDescription": "Source.coop で公開されているデータプロダクトを探す", + "title": "プロダクト", + "showingCount": "{count, plural, other {#件のプロダクトを表示中}}", + "searchBadge": "検索:「{search}」", + "tagsBadge": "タグ:{tags}", + "featured": "注目" + }, + "ProductsSkeleton": { + "navLabel": "プロダクト一覧を読み込み中" + }, + "ProfileImageUpload": { + "invalidType": "無効なファイル形式です。使用できる形式:{types}", + "tooLarge": "ファイルサイズは{size}MB未満にしてください。", + "uploadFailedStatus": "アップロードに失敗しました:{status}", + "uploadFailed": "アップロードに失敗しました", + "deleteFailed": "削除に失敗しました", + "titleIndividual": "プロフィール画像", + "titleOrganization": "組織のプロフィール画像", + "descriptionIndividual": "プロフィール画像をアップロードしてアカウントをカスタマイズしましょう。画像をアップロードしない場合は、Gravatarが表示されます。", + "descriptionOrganization": "組織のプロフィール画像をアップロードしてください。画像をアップロードしない場合は、デフォルトのアイコンが表示されます。", + "uploading": "アップロード中…", + "uploadNew": "新しい画像をアップロード", + "deleting": "削除中…", + "removeImage": "画像を削除", + "removeTitle": "プロフィール画像を削除", + "removeConfirmIndividual": "プロフィール画像を削除してもよろしいですか?アカウントにはGravatarが表示されるようになります。", + "removeConfirmOrganization": "プロフィール画像を削除してもよろしいですか?組織にはデフォルトのアイコンが表示されます。", + "removed": "✓ プロフィール画像を削除しました。", + "uploaded": "✓ プロフィール画像をアップロードしました。", + "guidelines": "画像のガイドライン", + "guidelineDimensions": "• 推奨サイズ:400×400ピクセル以上", + "guidelineSquare": "• プロフィール画像には正方形の画像が最適です", + "guidelineFormats": "• 対応形式:JPG、PNG、WebP、GIF", + "guidelineSize": "• 最大ファイルサイズ:5MB" + }, + "ProxyCredentialsGate": { + "error": "この非公開プロダクトのアクセス用認証情報を読み込めませんでした。もう一度お試しください。問題が解決しない場合は、ログアウトしてから再度ログインしてください。", + "tryAgain": "再試行", + "fetching": "アクセス用認証情報を取得中…" + }, + "RefreshListingButton": { + "refresh": "更新", + "refreshListing": "一覧を更新" + }, + "STACSection": { + "catalog": "STACカタログ:{url}" + }, + "SecretField": { + "keepCurrent": "現在の値を維持", + "stored": "保存済み", + "replace": "置き換え…" + }, + "ServiceAccountActions": { + "unauthenticated": "ログインが必要です。", + "checkFields": "強調表示された項目を確認してください。", + "notOwnerManager": "そのアカウントのサービスアカウントを管理する権限がありません。", + "cannotOwnAnother": "サービスアカウントは別のサービスアカウントを所有できません。", + "badSubject": "{subject} は、1つのリポジトリと1つのブランチ、タグ、または環境を指定していません。", + "accessReadOrWrite": "{product}:アクセス権は読み取りまたは書き込みのいずれかにしてください。", + "noSuchProduct": "{account} にプロダクト {product} はありません。", + "expiryRange": "有効期限は1日から3650日の間で指定してください。", + "keyLabelTooLong": "64文字以内で入力してください。", + "accountIdTaken": "そのアカウントIDはすでに使用されています。", + "localIdTaken": "{owner} にはすでに {id} という名前のサービスアカウントがあります。", + "notManaged": "そのサービスアカウントを管理する権限がありません。", + "disabled": "そのサービスアカウントは無効になっています。", + "nameOneRepo": "1つのリポジトリと1つのブランチ、タグ、または環境を指定してください。", + "alreadyTrusted": "すでに信頼済みです。", + "trusted": "信頼を追加しました", + "noSuchTrust": "このアカウントにその信頼設定はありません。", + "trustRemoved": "信頼設定を削除しました", + "accountDisabled": "サービスアカウントを無効にしました", + "accountEnabled": "サービスアカウントを有効にしました", + "nameSaved": "名前を保存しました" + }, + "ServiceAccountDetail": { + "actionsFor": "{subject} の操作", + "exampleUsage": "使用例", + "signInFromWorkflow": "このワークフローからログイン", + "whoItIs": "基本情報", + "disabled": "無効", + "name": "名前", + "accountId": "アカウント ID", + "accountIdHelp": "ソフトウェアがログインに使うハンドルです。作成後は変更できません。", + "signsInWith": "ログイン方法", + "signInDescription": "GitHub Actions のワークフロー(それぞれ1つのリポジトリと、1つのブランチ、タグ、または環境に固定)。実行のたびに GitHub が保証するため、保存すべきシークレットはありません。または、OIDC が使えない環境向けの APIキー(発行時に一度だけ表示)。漏えいしたキーは失効させてください。すべてのログインを一度に止めるには、下でアカウントを無効にします。", + "nothingYet": "まだありません。ワークフローを信頼するかキーを発行するまで、ログインできません。", + "githubWorkflows": "GitHub ワークフロー", + "apiKeys": "APIキー", + "canReach": "アクセス先", + "canReachDescription": "読み取りまたは書き込みができるプロダクトです。それぞれ新しいタブで開いて内容を確認できます。変更はすぐに保存され、次回のログインから反映されます。", + "dangerZone": "危険な操作", + "enable": "有効にする", + "disable": "無効にする", + "enableTitle": "{name} を有効にしますか?", + "disableTitle": "{name} を無効にしますか?", + "enableDescription": "信頼済みのワークフローと失効していない APIキーで再びログインできるようになります。漏えいしたキーは先に失効させてください。", + "disableDescription": "ワークフローでも APIキーでも、このアカウントとしてログインできなくなります。すでに生成された認証情報は5分以内に公開データのみに制限されます。ワークフロー、キー、プロダクトへのアクセスは保持されます。", + "deleteTitle": "{name} を削除しますか?", + "deleteDescription": "ワークフローと APIキーは使えなくなり、すべてのプロダクトへのアクセスを失います。すでに生成された認証情報は期限切れまで有効です。この操作は元に戻せません。", + "typeToConfirm": "確認のため {accountId} と入力してください" + }, + "ServiceAccountForm": { + "removeLabel": "{label} を削除", + "whoItIs": "基本情報", + "ownedBy": "オーナーは {owner} です。そのアカウントを管理するユーザーがこのアカウントも管理します。", + "name": "名前", + "namePlaceholder": "夜間同期", + "accountId": "アカウント ID", + "accountIdEditHelp": "ソフトウェアがログインに使うハンドルです。英小文字、数字、単一のハイフンが使えます。", + "accountIdHelp": "名前から生成されます。作成後は変更できません。", + "signsInWith": "ログイン方法", + "nothingYet": "まだありません。外部環境からのアクセスを許可するには、ワークフローまたはキーを追加してください。", + "githubWorkflows": "GitHub ワークフロー", + "apiKeys": "APIキー", + "expiresAfter": "{days, plural, other {発行から#日後に期限切れ}}", + "neverExpires": "無期限", + "canReach": "アクセス先", + "canReachDescription": "{owner} が所有するプロダクトのうち、読み取りまたは書き込みを許可するものです。各付与は通常のメンバーシップであり、人のメンバーシップと同じ方法で取り消せます。", + "submit": "サービスアカウントを作成" + }, + "ServiceAccountKeyActions": { + "notManaged": "そのサービスアカウントを管理する権限がありません。", + "disabled": "そのサービスアカウントは無効になっています。", + "expiryRange": "有効期限は1日から3650日の間で指定してください。", + "labelTooLong": "キーには64文字以内のラベルを付けてください。", + "noSuchKey": "管理しているサービスアカウントにそのキーはありません。", + "alreadyRevoked": "すでに無効化されています。", + "revoked": "キーを無効化しました", + "expiryUpdated": "有効期限を更新しました", + "noExpiry": "キーを無期限にしました" + }, + "ServiceAccountList": { + "emptyTitle": "サービスアカウントはまだありません", + "emptyBody": "夜間の同期、公開パイプライン、観測機器などのために作成できます。", + "disabled": "無効", + "workflows": "{count, plural, other {#件のワークフロー}}", + "apiKeys": "{count, plural, other {#件のAPIキー}}", + "products": "{count, plural, other {#件のプロダクト}}" + }, + "ServiceAccountPage": { + "metaTitle": "サービスアカウント", + "back": "サービスアカウント" + }, + "ServiceAccountsPage": { + "metaTitle": "サービスアカウント", + "title": "サービスアカウント", + "description": "ソフトウェア(夜間の同期、公開パイプライン、観測機器など)用のログインです。誰かのアカウントを共有することなく、付与や取り消しができます。", + "newServiceAccount": "新しいサービスアカウント" + }, + "ShortcutHelp": { + "title": "キーボードショートカット", + "descriptionProductList": "プロダクト一覧のキーボードショートカットです。これらのショートカットで画面の移動や操作ができます。", + "descriptionObjectBrowser": "オブジェクトブラウザのキーボードショートカットです。これらのショートカットで画面の移動や操作ができます。", + "descriptionObjectDetails": "オブジェクト詳細のキーボードショートカットです。これらのショートカットで画面の移動や操作ができます。", + "keyHeader": "キー", + "actionHeader": "操作", + "pressEscapeToClose": "Escapeキーで閉じる", + "toggleHelp": "このヘルプを表示/非表示", + "goHome": "ホームページへ移動", + "navigateUp": "上へ移動", + "navigateDown": "下へ移動", + "openSelectedProduct": "選択したプロダクトを開く", + "copySelectedProductUrl": "選択したプロダクトのURLをコピー", + "clearSelection": "選択を解除", + "openSelectedItem": "選択した項目を開く", + "copySelectedItemUrl": "選択した項目のURLをコピー", + "goUpOneLevel": "1つ上の階層へ移動", + "navigateUpProperties": "プロパティを上へ移動", + "navigateDownProperties": "プロパティを下へ移動", + "copySelectedPropertyValue": "選択したプロパティの値をコピー", + "returnToDirectoryView": "ディレクトリ表示に戻る" + }, + "SiteMetadata": { + "description": "オープンウェブのためのデータ公開ユーティリティ。" + }, + "StatusPage": { + "notFoundTitle": "404: ページが見つかりません", + "notFoundDescription": "お探しのページが見つかりませんでした。

    パスが移動されたか、存在しない可能性があります。", + "notAuthorizedTitle": "403: アクセス権限がありません", + "notAuthorizedDescription": "このリソースにアクセスする権限がありません。

    誤りだと思われる場合は、管理者にお問い合わせください。", + "unauthenticatedTitle": "ログインが必要です", + "unauthenticatedDescription": "このページにアクセスするにはログインが必要です。

    ログイン後、このページに戻ります。", + "returnHome": "ホームページに戻る" + }, + "UploadProvider": { + "leaveWarning": "ページを離れると、進行中のアップロードや削除が中断されます。", + "requestFailed": "リクエストに失敗しました", + "deletePartial": "削除が一部しか完了していない可能性があります({reason})。再試行して残りの項目を削除してください。", + "deleteFailed": "削除に失敗しました({reason})。再試行してください。" + }, + "UsageCard": { + "title": "アナリティクス", + "windowDays": "{days}日間" + }, + "ViewCredentialsDialog": { + "title": "認証情報を表示", + "description": "アプリケーションで使用するには、この一時的な認証情報をコピーしてください。", + "cliCallout": "もっと簡単な方法をお探しですか?Source Coop CLIを使うと認証情報が自動で管理され、より長く有効な認証情報(最大12時間)を利用できます。", + "tabJson": "JSON(SDK)", + "tabEnv": "環境変数", + "jsonHint": "SDKクライアント向け(boto3、AWS SDKなど)", + "envHint": "ターミナル・シェル向け", + "iniHint": "~/.aws/credentials 向け(AWS CLIプロファイル)", + "boundaries": "適用範囲", + "expiration": "有効期限", + "bucket": "バケット", + "prefix": "プレフィックス", + "copyToClipboard": "クリップボードにコピー" + }, + "WelcomeCallout": { + "message": "Source Cooperativeへようこそ。ここはあなたのプロフィールページです。編集して詳細を追加してください。" } } diff --git a/src/lib/actions/account.ts b/src/lib/actions/account.ts index c7c2b54d7..771736468 100644 --- a/src/lib/actions/account.ts +++ b/src/lib/actions/account.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { LOGGER } from "@/lib/logging"; import { Account, @@ -43,13 +44,14 @@ export async function createAccount( initialState: any, formData: FormData ): Promise> { + const t = await getTranslations("AccountActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -59,7 +61,7 @@ export async function createAccount( // Service accounts have their own action, createServiceAccount, which // settles the owner and composes the id. if (type === AccountType.SERVICE) { - return { fieldErrors: {}, data: formData, message: "Invalid form data", success: false }; + return { fieldErrors: {}, data: formData, message: t("invalidFormData"), success: false }; } const schema = type === AccountType.ORGANIZATION @@ -71,7 +73,7 @@ export async function createAccount( return { fieldErrors: validatedFields.error.flatten().fieldErrors, data: formData, - message: "Invalid form data", + message: t("invalidFormData"), success: false, }; } @@ -109,7 +111,7 @@ export async function createAccount( return { fieldErrors: {}, data: formData, - message: "Unauthorized to create this type of account", + message: t("unauthorizedCreate"), success: false, }; } @@ -130,9 +132,9 @@ export async function createAccount( metadata: { account_id: newAccount.account_id }, }); return { - fieldErrors: { account_id: ["That account ID is already taken."] }, + fieldErrors: { account_id: [t("accountIdTakenField")] }, data: formData, - message: "That account ID is already taken", + message: t("accountIdTaken"), success: false, }; } @@ -184,13 +186,14 @@ export async function updateAccountProfile( initialState: any, formData: FormData ): Promise> { + const t = await getTranslations("AccountActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -200,7 +203,7 @@ export async function updateAccountProfile( return { fieldErrors: {}, data: formData, - message: "Account ID is required", + message: t("accountIdRequired"), success: false, }; } @@ -212,7 +215,7 @@ export async function updateAccountProfile( return { fieldErrors: {}, data: formData, - message: "Account not found", + message: t("accountNotFound"), success: false, }; } @@ -222,7 +225,7 @@ export async function updateAccountProfile( return { fieldErrors: {}, data: formData, - message: "Unauthorized to update this account", + message: t("unauthorizedUpdate"), success: false, }; } @@ -280,9 +283,9 @@ export async function updateAccountProfile( // record each time would throw away the address's verified state. if (!AccountEmailSchema.shape.address.safeParse(email).success) { return { - fieldErrors: { email: ["Invalid email address"] }, + fieldErrors: { email: [t("invalidEmail")] }, data: formData, - message: "Invalid form data", + message: t("invalidFormData"), success: false, }; } @@ -328,7 +331,7 @@ export async function updateAccountProfile( return { fieldErrors: {}, data: formData, - message: "Profile updated successfully!", + message: t("profileUpdated"), success: true, }; } catch (error) { @@ -341,7 +344,7 @@ export async function updateAccountProfile( return { fieldErrors: {}, data: formData, - message: "Failed to update profile. Please try again.", + message: t("profileUpdateFailed"), success: false, }; } @@ -357,13 +360,14 @@ export async function updateAccountFlags( initialState: any, formData: FormData ): Promise> { + const t = await getTranslations("AccountActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -373,7 +377,7 @@ export async function updateAccountFlags( return { fieldErrors: {}, data: formData, - message: "Account ID is required", + message: t("accountIdRequired"), success: false, }; } @@ -385,7 +389,7 @@ export async function updateAccountFlags( return { fieldErrors: {}, data: formData, - message: "Account not found", + message: t("accountNotFound"), success: false, }; } @@ -395,7 +399,7 @@ export async function updateAccountFlags( return { fieldErrors: {}, data: formData, - message: "Unauthorized to update this account's flags", + message: t("unauthorizedUpdateFlags"), success: false, }; } @@ -415,7 +419,7 @@ export async function updateAccountFlags( return { fieldErrors: {}, data: formData, - message: "A service account cannot hold the admin flag", + message: t("serviceAccountNoAdmin"), success: false, }; } @@ -439,7 +443,7 @@ export async function updateAccountFlags( return { fieldErrors: {}, data: formData, - message: "Account flags updated successfully!", + message: t("flagsUpdated"), success: true, }; } catch (error) { @@ -452,7 +456,7 @@ export async function updateAccountFlags( return { fieldErrors: {}, data: formData, - message: "Failed to update account flags. Please try again.", + message: t("flagsUpdateFailed"), success: false, }; } diff --git a/src/lib/actions/data-connections.ts b/src/lib/actions/data-connections.ts index 164d1f357..68608d659 100644 --- a/src/lib/actions/data-connections.ts +++ b/src/lib/actions/data-connections.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { z } from "zod"; import { LOGGER } from "@/lib/logging"; import { @@ -61,13 +62,14 @@ export async function createDataConnection( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("DataConnectionActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -81,11 +83,11 @@ export async function createDataConnection( return { fieldErrors: { name: [ - `Name needs at least ${MIN_ID_LENGTH} letters or numbers, so an ID can be made from it.`, + t("nameTooShort", { min: MIN_ID_LENGTH }), ], }, data: formData, - message: "Invalid connection name", + message: t("invalidName"), success: false, }; } @@ -113,7 +115,7 @@ export async function createDataConnection( return { fieldErrors: fieldErrorsFromZod(validated.error), data: formData, - message: "Invalid form data", + message: t("invalidFormData"), success: false, }; } @@ -128,7 +130,7 @@ export async function createDataConnection( return { fieldErrors: {}, data: formData, - message: "Unauthorized to create data connections", + message: t("unauthorizedCreate"), success: false, }; } @@ -142,11 +144,11 @@ export async function createDataConnection( return { fieldErrors: { name: [ - `That name is already taken — it would produce the ID "${validated.data.data_connection_id}".`, + t("nameTakenField", { id: validated.data.data_connection_id }), ], }, data: formData, - message: "A data connection with that name already exists", + message: t("nameTaken"), success: false, }; } @@ -163,11 +165,11 @@ export async function createDataConnection( return { fieldErrors: { name: [ - `That name is already taken — it would produce the ID "${validated.data.data_connection_id}".`, + t("nameTakenField", { id: validated.data.data_connection_id }), ], }, data: formData, - message: "A data connection with that name already exists", + message: t("nameTaken"), success: false, }; } @@ -190,7 +192,7 @@ export async function createDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection created successfully!", + message: t("created"), success: true, redirectTo: owner ? accountDataConnectionEditUrl(owner, newId) @@ -205,7 +207,7 @@ export async function createDataConnection( return { fieldErrors: {}, data: formData, - message: "Failed to create data connection. Please try again.", + message: t("createFailed"), success: false, }; } @@ -215,13 +217,14 @@ export async function updateDataConnection( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("DataConnectionActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -231,7 +234,7 @@ export async function updateDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection ID is required", + message: t("idRequired"), success: false, }; } @@ -242,7 +245,7 @@ export async function updateDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection not found", + message: t("notFound"), success: false, }; } @@ -257,7 +260,7 @@ export async function updateDataConnection( return { fieldErrors: {}, data: formData, - message: "Unauthorized to update data connections", + message: t("unauthorizedUpdate"), success: false, }; } @@ -271,7 +274,7 @@ export async function updateDataConnection( return { fieldErrors: fieldErrorsFromZod(validated.error), data: formData, - message: "Invalid form data", + message: t("invalidFormData"), success: false, }; } @@ -288,7 +291,7 @@ export async function updateDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection was deleted concurrently", + message: t("deletedConcurrently"), success: false, }; } @@ -313,7 +316,7 @@ export async function updateDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection updated successfully!", + message: t("updated"), success: true, }; } catch (error) { @@ -325,7 +328,7 @@ export async function updateDataConnection( return { fieldErrors: {}, data: formData, - message: "Failed to update data connection. Please try again.", + message: t("updateFailed"), success: false, }; } @@ -335,13 +338,14 @@ export async function deleteDataConnection( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("DataConnectionActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -351,7 +355,7 @@ export async function deleteDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection ID is required", + message: t("idRequired"), success: false, }; } @@ -362,7 +366,7 @@ export async function deleteDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection not found", + message: t("notFound"), success: false, }; } @@ -377,7 +381,7 @@ export async function deleteDataConnection( return { fieldErrors: {}, data: formData, - message: "Unauthorized to delete data connections", + message: t("unauthorizedDelete"), success: false, }; } @@ -391,7 +395,7 @@ export async function deleteDataConnection( return { fieldErrors: {}, data: formData, - message: `Cannot delete: ${dependents.length} product(s) still use this connection. Remove it from them first.`, + message: t("inUse", { count: dependents.length }), success: false, }; } @@ -411,7 +415,7 @@ export async function deleteDataConnection( return { fieldErrors: {}, data: formData, - message: "Data connection deleted successfully!", + message: t("deleted"), success: true, redirectTo: listUrl, }; @@ -424,7 +428,7 @@ export async function deleteDataConnection( return { fieldErrors: {}, data: formData, - message: "Failed to delete data connection. Please try again.", + message: t("deleteFailed"), success: false, }; } diff --git a/src/lib/actions/memberships.ts b/src/lib/actions/memberships.ts index 08b583182..8668b8220 100644 --- a/src/lib/actions/memberships.ts +++ b/src/lib/actions/memberships.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { LOGGER } from "@/lib/logging"; import { Actions, @@ -30,13 +31,14 @@ export async function inviteMember( _initialState: any, formData: FormData ): Promise> { + const t = await getTranslations("MembershipActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -50,7 +52,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Missing required fields", + message: t("missingFields"), success: false, }; } @@ -62,7 +64,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Organization not found", + message: t("organizationNotFound"), success: false, }; } @@ -73,7 +75,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Account to invite not found", + message: t("inviteeNotFound"), success: false, }; } @@ -82,7 +84,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Organizations cannot be members", + message: t("organizationsCannotBeMembers"), success: false, }; } @@ -131,7 +133,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Unauthorized to invite members to this organization", + message: t("unauthorizedInvite"), success: false, }; } @@ -151,7 +153,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "User is already a member or has a pending invitation", + message: t("alreadyMember"), success: false, }; } @@ -171,7 +173,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Member invited successfully!", + message: t("invited"), success: true, }; } catch (error) { @@ -184,7 +186,7 @@ export async function inviteMember( return { fieldErrors: {}, data: formData, - message: "Failed to invite member. Please try again.", + message: t("inviteFailed"), success: false, }; } @@ -199,27 +201,28 @@ export async function inviteMember( export async function acceptInvitation( membershipId: string ): Promise<{ success: boolean; error?: string }> { + const t = await getTranslations("MembershipActions"); const session = await getPageSession(); if (!session?.identity_id) { - return { success: false, error: "Unauthenticated" }; + return { success: false, error: t("unauthenticated") }; } try { const membership = await membershipsTable.fetchById(membershipId); if (!membership) { - return { success: false, error: "Membership not found" }; + return { success: false, error: t("membershipNotFound") }; } if (!isAuthorized(session, membership, Actions.AcceptMembership)) { - return { success: false, error: "Unauthorized to accept this invitation" }; + return { success: false, error: t("unauthorizedAccept") }; } if ( membership.state === MembershipState.Member || membership.state === MembershipState.Revoked ) { - return { success: false, error: "Membership is not in a pending state" }; + return { success: false, error: t("notPending") }; } await membershipsTable.update({ @@ -245,7 +248,7 @@ export async function acceptInvitation( error: error, }); - return { success: false, error: "Failed to accept invitation" }; + return { success: false, error: t("acceptFailed") }; } } @@ -258,27 +261,28 @@ export async function acceptInvitation( export async function rejectInvitation( membershipId: string ): Promise<{ success: boolean; error?: string }> { + const t = await getTranslations("MembershipActions"); const session = await getPageSession(); if (!session?.identity_id) { - return { success: false, error: "Unauthenticated" }; + return { success: false, error: t("unauthenticated") }; } try { const membership = await membershipsTable.fetchById(membershipId); if (!membership) { - return { success: false, error: "Membership not found" }; + return { success: false, error: t("membershipNotFound") }; } if (!isAuthorized(session, membership, Actions.RejectMembership)) { - return { success: false, error: "Unauthorized to reject this invitation" }; + return { success: false, error: t("unauthorizedReject") }; } if ( membership.state === MembershipState.Member || membership.state === MembershipState.Revoked ) { - return { success: false, error: "Membership is not in a pending state" }; + return { success: false, error: t("notPending") }; } await membershipsTable.update({ @@ -304,7 +308,7 @@ export async function rejectInvitation( error: error, }); - return { success: false, error: "Failed to reject invitation" }; + return { success: false, error: t("rejectFailed") }; } } @@ -360,13 +364,14 @@ export async function revokeMembership( _initialState: any, formData: FormData ): Promise> { + const t = await getTranslations("MembershipActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -377,7 +382,7 @@ export async function revokeMembership( return { fieldErrors: {}, data: formData, - message: "Missing membership ID", + message: t("missingMembershipId"), success: false, }; } @@ -389,7 +394,7 @@ export async function revokeMembership( return { fieldErrors: {}, data: formData, - message: "Membership not found", + message: t("membershipNotFound"), success: false, }; } @@ -399,7 +404,7 @@ export async function revokeMembership( return { fieldErrors: {}, data: formData, - message: "Unauthorized to revoke this membership", + message: t("unauthorizedRevoke"), success: false, }; } @@ -427,7 +432,7 @@ export async function revokeMembership( return { fieldErrors: {}, data: formData, - message: "Membership revoked successfully!", + message: t("revoked"), success: true, }; } catch (error) { @@ -440,7 +445,7 @@ export async function revokeMembership( return { fieldErrors: {}, data: formData, - message: "Failed to revoke membership. Please try again.", + message: t("revokeFailed"), success: false, }; } diff --git a/src/lib/actions/product-mirrors.ts b/src/lib/actions/product-mirrors.ts index ccf63937b..1904c765b 100644 --- a/src/lib/actions/product-mirrors.ts +++ b/src/lib/actions/product-mirrors.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { LOGGER } from "@/lib/logging"; import { canManageAccount } from "../api/authz"; import { getPageSession } from "../api/utils"; @@ -22,16 +23,14 @@ import { * Returns the message to report when the caller is not permitted, or null when * they are. */ -const NOT_ACCOUNT_MANAGER_MESSAGE = - "Only owners and maintainers of the owning account can manage this product's data connections"; - async function denyUnlessAccountManager( session: Awaited>, accountId: string ): Promise { + const t = await getTranslations("ProductMirrorActions"); const account = await accountsTable.fetchById(accountId); if (!account || !canManageAccount(session, account)) { - return NOT_ACCOUNT_MANAGER_MESSAGE; + return t("notAccountManager"); } return null; } @@ -40,9 +39,6 @@ async function denyUnlessAccountManager( // optimistic compare-and-swap on the product's updated_at, so two people editing // the same product's mirrors concurrently can't silently clobber each other — // the second write fails and the action reports a conflict instead. -const CONCURRENT_EDIT_MESSAGE = - "This product was modified by someone else. Please reload and try again."; - function isConcurrentEdit(error: unknown): boolean { return ( error instanceof Error && @@ -54,13 +50,14 @@ export async function addProductMirror( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("ProductMirrorActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -73,7 +70,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "Missing required fields", + message: t("missingFields"), success: false, }; } @@ -91,7 +88,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "Product not found", + message: t("productNotFound"), success: false, }; } @@ -101,7 +98,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "Data connection not found", + message: t("connectionNotFound"), success: false, }; } @@ -112,7 +109,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "This data connection is not available for this account", + message: t("connectionUnavailable"), success: false, }; } @@ -121,7 +118,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "This data connection is already associated with this product", + message: t("alreadyAssociated"), success: false, }; } @@ -163,7 +160,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "Data connection added successfully!", + message: t("added"), success: true, }; } catch (error) { @@ -171,7 +168,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: CONCURRENT_EDIT_MESSAGE, + message: t("concurrentEdit"), success: false, }; } @@ -182,7 +179,7 @@ export async function addProductMirror( return { fieldErrors: {}, data: formData, - message: "Failed to add data connection. Please try again.", + message: t("addFailed"), success: false, }; } @@ -192,13 +189,14 @@ export async function removeProductMirror( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("ProductMirrorActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -211,7 +209,7 @@ export async function removeProductMirror( return { fieldErrors: {}, data: formData, - message: "Missing required fields", + message: t("missingFields"), success: false, }; } @@ -227,7 +225,7 @@ export async function removeProductMirror( return { fieldErrors: {}, data: formData, - message: "Product not found", + message: t("productNotFound"), success: false, }; } @@ -236,7 +234,7 @@ export async function removeProductMirror( return { fieldErrors: {}, data: formData, - message: "Mirror not found", + message: t("mirrorNotFound"), success: false, }; } @@ -280,7 +278,7 @@ export async function removeProductMirror( return { fieldErrors: {}, data: formData, - message: "Data connection removed successfully!", + message: t("removed"), success: true, }; } catch (error) { @@ -288,7 +286,7 @@ export async function removeProductMirror( return { fieldErrors: {}, data: formData, - message: CONCURRENT_EDIT_MESSAGE, + message: t("concurrentEdit"), success: false, }; } @@ -299,7 +297,7 @@ export async function removeProductMirror( return { fieldErrors: {}, data: formData, - message: "Failed to remove data connection. Please try again.", + message: t("removeFailed"), success: false, }; } @@ -317,13 +315,14 @@ export async function updateMirrorPrefix( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("ProductMirrorActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -337,7 +336,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: "Missing required fields", + message: t("missingFields"), success: false, }; } @@ -353,7 +352,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: "Prefix can't start with '/' or contain '..'", + message: t("invalidPrefix"), success: false, }; } @@ -371,7 +370,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: "Product not found", + message: t("productNotFound"), success: false, }; } @@ -381,7 +380,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: "Mirror not found", + message: t("mirrorNotFound"), success: false, }; } @@ -395,7 +394,7 @@ export async function updateMirrorPrefix( fieldErrors: {}, data: formData, message: - "You must be able to manage both this account and the data connection to edit its prefix", + t("cannotEditPrefix"), success: false, }; } @@ -425,7 +424,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: "Prefix updated successfully!", + message: t("prefixUpdated"), success: true, }; } catch (error) { @@ -433,7 +432,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: CONCURRENT_EDIT_MESSAGE, + message: t("concurrentEdit"), success: false, }; } @@ -444,7 +443,7 @@ export async function updateMirrorPrefix( return { fieldErrors: {}, data: formData, - message: "Failed to update prefix. Please try again.", + message: t("prefixUpdateFailed"), success: false, }; } @@ -454,13 +453,14 @@ export async function setPrimaryMirror( _prevState: FormState, formData: FormData ): Promise> { + const t = await getTranslations("ProductMirrorActions"); const session = await getPageSession(); if (!session?.identity_id) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -473,7 +473,7 @@ export async function setPrimaryMirror( return { fieldErrors: {}, data: formData, - message: "Missing required fields", + message: t("missingFields"), success: false, }; } @@ -489,7 +489,7 @@ export async function setPrimaryMirror( return { fieldErrors: {}, data: formData, - message: "Product not found", + message: t("productNotFound"), success: false, }; } @@ -498,7 +498,7 @@ export async function setPrimaryMirror( return { fieldErrors: {}, data: formData, - message: "Mirror not found", + message: t("mirrorNotFound"), success: false, }; } @@ -534,7 +534,7 @@ export async function setPrimaryMirror( return { fieldErrors: {}, data: formData, - message: "Primary mirror updated successfully!", + message: t("primaryUpdated"), success: true, }; } catch (error) { @@ -542,7 +542,7 @@ export async function setPrimaryMirror( return { fieldErrors: {}, data: formData, - message: CONCURRENT_EDIT_MESSAGE, + message: t("concurrentEdit"), success: false, }; } @@ -553,7 +553,7 @@ export async function setPrimaryMirror( return { fieldErrors: {}, data: formData, - message: "Failed to update primary mirror. Please try again.", + message: t("primaryUpdateFailed"), success: false, }; } diff --git a/src/lib/actions/products.ts b/src/lib/actions/products.ts index 772667400..9d5860a32 100644 --- a/src/lib/actions/products.ts +++ b/src/lib/actions/products.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { productsTable, membershipsTable, dataConnectionsTable } from "@/lib/clients/database"; import { Actions, @@ -104,13 +105,14 @@ export async function createProduct( initialState: any, formData: FormData, ): Promise> { + const t = await getTranslations("ProductActions"); const session = await getPageSession(); if (!session?.identity_id || !session.account) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -123,7 +125,7 @@ export async function createProduct( return { fieldErrors: validatedFields.error.flatten().fieldErrors, data: formData, - message: "Invalid form data", + message: t("invalidFormData"), success: false, }; } @@ -145,7 +147,7 @@ export async function createProduct( return { fieldErrors: {}, data: formData, - message: "Unauthorized to create product", + message: t("unauthorizedCreate"), success: false, }; } @@ -153,9 +155,9 @@ export async function createProduct( const dataConnectionId = formData.get("data_connection_id"); if (typeof dataConnectionId !== "string" || dataConnectionId.length === 0) { return { - fieldErrors: { data_connection_id: ["A data connection is required"] }, + fieldErrors: { data_connection_id: [t("connectionRequired")] }, data: formData, - message: "Invalid form data", + message: t("invalidFormData"), success: false, }; } @@ -164,10 +166,10 @@ export async function createProduct( if (!dataConnection) { return { fieldErrors: { - data_connection_id: ["Selected data connection was not found"], + data_connection_id: [t("connectionNotFoundField")], }, data: formData, - message: "Invalid data connection", + message: t("invalidConnection"), success: false, }; } @@ -185,18 +187,18 @@ export async function createProduct( return { fieldErrors: {}, data: formData, - message: "You are not permitted to use the selected data connection", + message: t("connectionNotPermitted"), success: false, }; case "wrong-account": return { fieldErrors: { data_connection_id: [ - "Selected data connection is not available for this account", + t("connectionWrongAccountField"), ], }, data: formData, - message: "Invalid data connection for this account", + message: t("connectionWrongAccount"), success: false, }; } @@ -209,11 +211,11 @@ export async function createProduct( return { fieldErrors: { visibility: [ - `The "${dataConnection.name}" data connection does not allow ${validatedFields.data.visibility} products`, + t("visibilityNotAllowed", { name: dataConnection.name, visibility: validatedFields.data.visibility }), ], }, data: formData, - message: "Invalid visibility for the selected data connection", + message: t("invalidVisibility"), success: false, }; } @@ -268,13 +270,14 @@ export async function updateProduct( initialState: any, formData: FormData, ): Promise>> { + const t = await getTranslations("ProductActions"); const session = await getPageSession(); if (!session?.identity_id || !session.account) { return { fieldErrors: {}, data: formData, - message: "Unauthenticated", + message: t("unauthenticated"), success: false, }; } @@ -286,7 +289,7 @@ export async function updateProduct( return { fieldErrors: {}, data: formData, - message: "Account ID and Product ID are required", + message: t("idsRequired"), success: false, }; } @@ -298,7 +301,7 @@ export async function updateProduct( return { fieldErrors: {}, data: formData, - message: "Product not found", + message: t("productNotFound"), success: false, }; } @@ -308,7 +311,7 @@ export async function updateProduct( return { fieldErrors: {}, data: formData, - message: "Unauthorized to update this product", + message: t("unauthorizedUpdate"), success: false, }; } @@ -345,11 +348,11 @@ export async function updateProduct( return { fieldErrors: { visibility: [ - "This product's data connection could not be found, so its visibility cannot be changed", + t("connectionMissingField"), ], }, data: formData, - message: "Data connection not found for this product", + message: t("connectionMissing"), success: false, }; } @@ -358,11 +361,11 @@ export async function updateProduct( return { fieldErrors: { visibility: [ - `The "${dataConnection.name}" data connection does not allow ${visibility} products`, + t("visibilityNotAllowed", { name: dataConnection.name, visibility }), ], }, data: formData, - message: "Invalid visibility for the product's data connection", + message: t("invalidVisibilityForProduct"), success: false, }; } @@ -394,7 +397,7 @@ export async function updateProduct( return { fieldErrors: {}, data: formData, - message: "Product updated successfully!", + message: t("updated"), success: true, }; } catch (error) { @@ -408,7 +411,7 @@ export async function updateProduct( return { fieldErrors: {}, data: formData, - message: "Failed to update product. Please try again.", + message: t("updateFailed"), success: false, }; } @@ -419,19 +422,21 @@ export async function deleteProduct( product_id: string, preserveData: boolean = false ): Promise<{ success: boolean; error?: string }> { + const t = await getTranslations("ProductActions"); + const tCommon = await getTranslations("Common"); const session = await getPageSession(); if (!session?.identity_id || !session.account) { - return { success: false, error: "Unauthenticated" }; + return { success: false, error: t("unauthenticated") }; } const product = await productsTable.fetchById(account_id, product_id); if (!product) { - return { success: false, error: "Product not found" }; + return { success: false, error: t("productNotFound") }; } if (!isAuthorized(session, product, Actions.DeleteRepository)) { - return { success: false, error: "Unauthorized to delete this product" }; + return { success: false, error: t("unauthorizedDelete") }; } try { @@ -452,7 +457,7 @@ export async function deleteProduct( if (preserveData && !canPreserveData) { return { success: false, - error: "You are not permitted to keep this product's data when deleting it.", + error: t("cannotPreserveData"), }; } @@ -497,7 +502,7 @@ export async function deleteProduct( // Surface the underlying cause (e.g. S3 "Access Denied") instead of a // generic "try again" — most failures here are not transient. - const reason = error instanceof Error ? error.message : "Unknown error"; - return { success: false, error: `Failed to delete product: ${reason}` }; + const reason = error instanceof Error ? error.message : tCommon("unknownError"); + return { success: false, error: t("deleteFailed", { reason }) }; } } diff --git a/src/lib/actions/service-account-keys.ts b/src/lib/actions/service-account-keys.ts index 685d32254..b68ba1961 100644 --- a/src/lib/actions/service-account-keys.ts +++ b/src/lib/actions/service-account-keys.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { revalidatePath } from "next/cache"; import { LOGGER } from "@/lib/logging"; import { publicKey, type ApiKeyActionState } from "@/types"; @@ -23,15 +24,16 @@ export async function issueApiKey( _prev: ApiKeyActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountKeyActions"); const session = await getPageSession(); const account = await managedServiceAccount(session, String(formData.get("account_id") ?? "")); if (!account || !session?.account) { - return outcome("You do not manage that service account", false); + return outcome(t("notManaged"), false); } // Disabled means frozen: no new key until it is enabled again. - if (account.disabled) return outcome("That service account is disabled", false); + if (account.disabled) return outcome(t("disabled"), false); const expires_at = expiryFrom(formData); - if (expires_at === undefined) return outcome("Expiry must be between 1 and 3650 days", false); + if (expires_at === undefined) return outcome(t("expiryRange"), false); const minted = mintApiKey({ account_id: account.account_id, @@ -39,7 +41,7 @@ export async function issueApiKey( created_by: session.account.account_id, expires_at, }); - if (!minted) return outcome("Give the key a label of up to 64 characters", false); + if (!minted) return outcome(t("labelTooLong"), false); const { key, record } = minted; await serviceAccountKeysTable.create(record); @@ -69,13 +71,14 @@ export async function revokeApiKey( _prev: ApiKeyActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountKeyActions"); const own = await ownKey(formData); - if (!own) return outcome("No such key on a service account you manage", false); - if (own.key.revoked_at) return outcome("Already revoked", false); + if (!own) return outcome(t("noSuchKey"), false); + if (own.key.revoked_at) return outcome(t("alreadyRevoked"), false); await serviceAccountKeysTable.revoke(own.key.key_hash, "owner", own.session?.account?.account_id); revalidatePath(editAccountServiceAccountsUrl(own.account.owner_account_id)); revalidatePath(editServiceAccountUrl(own.account.owner_account_id, own.account.account_id)); - return outcome("Key revoked", true); + return outcome(t("revoked"), true); } /** Expiry may be extended for a workload that needs longer, or shortened during an incident. */ @@ -83,12 +86,13 @@ export async function setApiKeyExpiry( _prev: ApiKeyActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountKeyActions"); const own = await ownKey(formData); - if (!own) return outcome("No such key on a service account you manage", false); + if (!own) return outcome(t("noSuchKey"), false); const expires_at = expiryFrom(formData); - if (expires_at === undefined) return outcome("Expiry must be between 1 and 3650 days", false); + if (expires_at === undefined) return outcome(t("expiryRange"), false); await serviceAccountKeysTable.set(own.key.key_hash, "expires_at", expires_at); revalidatePath(editAccountServiceAccountsUrl(own.account.owner_account_id)); revalidatePath(editServiceAccountUrl(own.account.owner_account_id, own.account.account_id)); - return outcome(expires_at ? "Expiry updated" : "Key no longer expires", true); + return outcome(expires_at ? t("expiryUpdated") : t("noExpiry"), true); } diff --git a/src/lib/actions/service-accounts.ts b/src/lib/actions/service-accounts.ts index 1af9850ed..1ff04932d 100644 --- a/src/lib/actions/service-accounts.ts +++ b/src/lib/actions/service-accounts.ts @@ -1,5 +1,6 @@ "use server"; +import { getTranslations } from "next-intl/server"; import { revalidatePath } from "next/cache"; import { LOGGER } from "@/lib/logging"; import { @@ -70,8 +71,9 @@ export async function createServiceAccount( _prev: ServiceAccountFormState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const session = await getPageSession(); - if (!session?.identity_id || !session.account) return fail("Unauthenticated"); + if (!session?.identity_id || !session.account) return fail(t("unauthenticated")); const parsed = ServiceAccountCreationRequestSchema.safeParse({ local_id: formData.get("local_id"), @@ -79,7 +81,7 @@ export async function createServiceAccount( owner_account_id: formData.get("owner_account_id"), }); if (!parsed.success) { - return fail("Check the highlighted fields", parsed.error.flatten().fieldErrors); + return fail(t("checkFields"), parsed.error.flatten().fieldErrors); } const { local_id, name, owner_account_id } = parsed.data; const account_id = serviceAccountId(owner_account_id, local_id); @@ -88,10 +90,10 @@ export async function createServiceAccount( // checks below cannot be used to probe another account's products. const owner = await accountsTable.fetchById(owner_account_id); if (!owner || !canManageAccountServiceAccounts(session, owner)) { - return fail("You do not manage service accounts for that account"); + return fail(t("notOwnerManager")); } if (isServiceAccount(owner)) { - return fail("A service account cannot own another"); + return fail(t("cannotOwnAnother")); } const now = new Date().toISOString(); @@ -112,7 +114,7 @@ export async function createServiceAccount( const subjects = [...new Set(formData.getAll("github_subject").map(String).filter(Boolean))]; const badSubject = subjects.find((s) => !GITHUB_ACTIONS_SUBJECT_REGEX.test(s)); if (badSubject) { - return fail(`${badSubject} does not name one repository and one branch, tag or environment`); + return fail(t("badSubject", { subject: badSubject })); } const grants: { product_id: string; role: MembershipRole }[] = []; @@ -121,10 +123,10 @@ export async function createServiceAccount( const product_id = key.slice("grant:".length); const role = formData.get(key) as MembershipRole; if (![MembershipRole.ReadData, MembershipRole.WriteData].includes(role)) { - return fail(`${product_id}: access must be read or write`); + return fail(t("accessReadOrWrite", { product: product_id })); } if (!(await productsTable.fetchById(owner_account_id, product_id))) { - return fail(`${owner_account_id} has no product ${product_id}`); + return fail(t("noSuchProduct", { account: owner_account_id, product: product_id })); } grants.push({ product_id, role }); } @@ -133,7 +135,7 @@ export async function createServiceAccount( let minted: ReturnType = null; if (formData.has("key_label")) { const expires_at = expiryFrom(formData); - if (expires_at === undefined) return fail("Expiry must be between 1 and 3650 days"); + if (expires_at === undefined) return fail(t("expiryRange")); minted = mintApiKey({ account_id, label: String(formData.get("key_label")), @@ -141,7 +143,7 @@ export async function createServiceAccount( expires_at, }); if (!minted) { - return fail("Check the highlighted fields", { key_label: ["Up to 64 characters."] }); + return fail(t("checkFields"), { key_label: [t("keyLabelTooLong")] }); } } @@ -149,8 +151,8 @@ export async function createServiceAccount( await accountsTable.create(account); } catch (error) { if ((error as { name?: string })?.name === "ConditionalCheckFailedException") { - return fail("That account ID is already taken", { - local_id: [`${owner_account_id} already has a service account called ${local_id}.`], + return fail(t("accountIdTaken"), { + local_id: [t("localIdTaken", { owner: owner_account_id, id: local_id })], }); } throw error; @@ -199,58 +201,61 @@ export async function addGithubTrust( _prev: ServiceAccountActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const session = await getPageSession(); const account = await managedServiceAccount(session, String(formData.get("account_id") ?? "")); if (!account || !session?.account) { - return outcome("You do not manage that service account", false); + return outcome(t("notManaged"), false); } // Disabled means frozen: nothing new may act as it until it is enabled. - if (account.disabled) return outcome("That service account is disabled", false); + if (account.disabled) return outcome(t("disabled"), false); const subject = String(formData.get("subject") ?? ""); if (!GITHUB_ACTIONS_SUBJECT_REGEX.test(subject)) { - return outcome("Name one repository and one branch, tag or environment", false); + return outcome(t("nameOneRepo"), false); } try { await trustGithub(account.account_id, subject, session.account.account_id); } catch (error) { - if (error instanceof AlreadyTrustedError) return outcome("Already trusted", false); + if (error instanceof AlreadyTrustedError) return outcome(t("alreadyTrusted"), false); throw error; } revalidate(account); - return outcome("Trusted", true); + return outcome(t("trusted"), true); } export async function removeTrust( _prev: ServiceAccountActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const account = await managedServiceAccount( await getPageSession(), String(formData.get("account_id") ?? "") ); - if (!account) return outcome("You do not manage that service account", false); + if (!account) return outcome(t("notManaged"), false); const issuer = String(formData.get("issuer") ?? ""); const subject = String(formData.get("subject") ?? ""); - if (!issuer || !subject) return outcome("No such trust on this account", false); + if (!issuer || !subject) return outcome(t("noSuchTrust"), false); // Keyed by the account, so this can only ever touch its own trusts. await accountTrustsTable.delete(account.account_id, issuer, subject); revalidate(account); - return outcome("Trust removed", true); + return outcome(t("trustRemoved"), true); } export async function setServiceAccountDisabled( _prev: ServiceAccountActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const account = await managedServiceAccount( await getPageSession(), String(formData.get("account_id") ?? "") ); - if (!account) return outcome("You do not manage that service account", false); + if (!account) return outcome(t("notManaged"), false); const disabled = formData.get("disabled") === "true"; await accountsTable.update({ ...account, disabled, updated_at: new Date().toISOString() }); revalidate(account); - return outcome(disabled ? "Service account disabled" : "Service account enabled", true); + return outcome(disabled ? t("accountDisabled") : t("accountEnabled"), true); } /** Changes the display name; the id, which software signs in as, stays. */ @@ -258,13 +263,14 @@ export async function renameServiceAccount( _prev: ServiceAccountActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const account = await managedFrom(formData); - if (!account) return outcome("You do not manage that service account", false); + if (!account) return outcome(t("notManaged"), false); const parsed = ServiceAccountCreationRequestSchema.shape.name.safeParse(formData.get("name")); if (!parsed.success) return outcome(parsed.error.issues[0].message, false); await accountsTable.update({ ...account, name: parsed.data, updated_at: new Date().toISOString() }); revalidate(account); - return outcome("Name saved", true); + return outcome(t("nameSaved"), true); } /** Removes the account, every grant it held, and every way it could sign in. */ @@ -272,11 +278,12 @@ export async function deleteServiceAccount( _prev: ServiceAccountActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const account = await managedServiceAccount( await getPageSession(), String(formData.get("account_id") ?? "") ); - if (!account) return outcome("You do not manage that service account", false); + if (!account) return outcome(t("notManaged"), false); for (const membership of await membershipsTable.listByUser(account.account_id)) { await membershipsTable.delete(membership.membership_id); } @@ -304,8 +311,9 @@ export async function setProductAccess( _prev: ServiceAccountActionState, formData: FormData ): Promise { + const t = await getTranslations("ServiceAccountActions"); const account = await managedFrom(formData); - if (!account) return outcome("You do not manage that service account", false); + if (!account) return outcome(t("notManaged"), false); const repository_id = String(formData.get("product_id") ?? ""); const access = String(formData.get("access") ?? ""); const held = (await membershipsTable.listByUser(account.account_id)).find( @@ -329,7 +337,7 @@ export async function setProductAccess( await membershipsTable.update({ ...held, role, state_changed: now }); } else { if (!(await productsTable.fetchById(account.owner_account_id, repository_id))) { - return outcome(`${account.owner_account_id} has no product ${repository_id}`, false); + return outcome(t("noSuchProduct", { account: account.owner_account_id, product: repository_id }), false); } await membershipsTable.create({ membership_id: randomUUID(), diff --git a/src/lib/format.ts b/src/lib/format.ts index 1ac559382..1bed70584 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -12,48 +12,6 @@ export function formatBytes(bytes: number, decimals: number = 2): string { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`; } -/** - * Format a date string into a human-readable format that's safe for SSR - * @param date The date string to format - * @returns A formatted date string like "5 Feb 2024" - */ -export function formatDateSSR(date: string): string { - const dateObj = new Date(date); - const day = dateObj.getUTCDate(); - const month = dateObj.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); - const year = dateObj.getUTCFullYear(); - return `${day} ${month} ${year}`; -} - -/** - * Format a date string into a human-readable format with optional time - * @param date The date string to format - * @param includeTime Whether to include the time in the output - * @returns A formatted date string - */ -export function formatDate(date: string, includeTime: boolean = false): string { - const dateObj = new Date(date); - - if (!includeTime) { - // Match the SSR format exactly - return formatDateSSR(date); - } - - // For timestamps, use Intl.DateTimeFormat but maintain consistent date format - const day = dateObj.getUTCDate(); - const month = dateObj.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); - const year = dateObj.getUTCFullYear(); - const time = new Intl.DateTimeFormat('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: 'UTC', - timeZoneName: 'short' - }).format(dateObj); - - return `${day} ${month} ${year} ${time}`; -} - const ELLIPSIS = "…"; /** diff --git a/src/lib/index.ts b/src/lib/index.ts index 11732ae07..cdfbebe6b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,5 +1,5 @@ // Export date formatting functions -export { formatDate, formatDateSSR, formatBytes } from "./format"; +export { formatBytes } from "./format"; // Export any other utilities that may be needed by components export * from "./api/utils"; From 44d2c1c6ac8f128515c3bc387c953d4148440f21 Mon Sep 17 00:00:00 2001 From: Anthony Lukach Date: Fri, 9 Oct 2026 14:08:14 -0600 Subject: [PATCH 3/3] feat(i18n): open a link in a given language with ?lang= A ?lang=ja query parameter on any URL sets the locale cookie the footer switcher writes and redirects to the same URL without the parameter, so a sender can choose the language a link opens in and the parameter isn't passed along when the page is shared again. Unsupported values are dropped without changing anything. Co-Authored-By: Claude Opus 5.5 --- src/middleware.test.ts | 28 +++++++++++++++++++++++++++- src/middleware.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 9731901a5..bc316d6e2 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -3,7 +3,11 @@ * failure mode is a silently dead ANALYTICS tab — pin its behavior. */ import { NextRequest } from "next/server"; -import { handleProductAnalyticsTab, restoreOryCookies } from "./middleware"; +import { + handleLocaleParam, + handleProductAnalyticsTab, + restoreOryCookies, +} from "./middleware"; const rewriteTarget = (url: string): string | null => handleProductAnalyticsTab(new NextRequest(url))?.headers.get( @@ -55,3 +59,25 @@ describe("restoreOryCookies", () => { expect(restoreOryCookies(null)).toBeNull(); }); }); + +describe("handleLocaleParam", () => { + const run = (url: string) => handleLocaleParam(new NextRequest(url)); + + it("sets the locale cookie and redirects to the URL without ?lang", () => { + const response = run("https://source.coop/acme/climate?lang=ja&tab=x"); + expect(response?.headers.get("location")).toBe( + "https://source.coop/acme/climate?tab=x", + ); + expect(response?.cookies.get("NEXT_LOCALE")?.value).toBe("ja"); + }); + + it("drops an unsupported value without setting the cookie", () => { + const response = run("https://source.coop/?lang=xx"); + expect(response?.headers.get("location")).toBe("https://source.coop/"); + expect(response?.cookies.get("NEXT_LOCALE")).toBeUndefined(); + }); + + it("ignores requests without ?lang", () => { + expect(run("https://source.coop/acme/climate")).toBeNull(); + }); +}); diff --git a/src/middleware.ts b/src/middleware.ts index 1338a83fb..18a7b029a 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -2,6 +2,29 @@ import { createOryMiddleware } from "@ory/nextjs/middleware"; import { NextRequest, NextResponse } from "next/server"; import { LOGGER } from "@/lib"; import { productUrl } from "@/lib/urls"; +import { LOCALE_COOKIE, LOCALES, type Locale } from "@/i18n/locales"; + +/** + * `?lang=ja` on any link opens it in that language. The choice becomes the + * same cookie the footer's language switcher writes, and the parameter is + * dropped from the URL so it isn't passed along when the page is shared again. + * An unsupported value is dropped without changing anything. + */ +export const handleLocaleParam = (request: NextRequest): NextResponse | null => { + const lang = request.nextUrl.searchParams.get("lang"); + if (lang === null) return null; + const url = request.nextUrl.clone(); + url.searchParams.delete("lang"); + const response = NextResponse.redirect(url); + if (LOCALES.includes(lang as Locale)) { + response.cookies.set(LOCALE_COOKIE, lang, { + path: "/", + maxAge: 31_536_000, + sameSite: "lax", + }); + } + return response; +}; /** * Ignore Chrome DevTools requests by returning 404. @@ -138,7 +161,11 @@ const ORY_PROXIED_PREFIXES = [ ]; export const middleware = async (request: NextRequest) => { - for (const handler of [handleLegacyRedirects, handleChromeDevTools]) { + for (const handler of [ + handleLocaleParam, + handleLegacyRedirects, + handleChromeDevTools, + ]) { const response = await handler(request); if (response) return response; }