diff --git a/packages/locales/lib/human/en.json b/packages/locales/lib/human/en.json index 6eca60e3f..6977fce0e 100644 --- a/packages/locales/lib/human/en.json +++ b/packages/locales/lib/human/en.json @@ -765,5 +765,18 @@ "reset_spawnpoints": "Reset Spawnpoints", "reset_submission_cells": "Reset Submission Cells", "hisuian": "Hisuian", - "spacial_rend_range": "Spacial Rend Range" + "spacial_rend_range": "Spacial Rend Range", + "key": "Key", + "ai": "AI", + "human": "Human", + "locales": "Locales", + "instructions": "Instructions", + "locale_instructions_1": "Select a language from the dropdown", + "locale_instructions_2": "Enter the desired translations in the \"Human\" column", + "locale_instructions_3": "Click the \"$t(download)\" button to download a JSON file", + "locale_instructions_4": "Fork the GitHub repo link below", + "locale_instructions_5": "Create a new branch and name it the language you are translating", + "locale_instructions_6": "Replace the contents of \"packages/locales/lib/human/{{lng}}.json\" with the file you downloaded", + "locale_instructions_7": "Create a pull request", + "locale_instructions_8": "Wait for the pull request to be reviewed and merged" } diff --git a/packages/locales/lib/index.js b/packages/locales/lib/index.js index 7f66f015c..900005dde 100644 --- a/packages/locales/lib/index.js +++ b/packages/locales/lib/index.js @@ -1,7 +1,12 @@ const { create } = require('./create') const { missing } = require('./missing') const { generate } = require('./generate') -const { readLocaleDirectory, writeAll, getStatus } = require('./utils') +const { + readLocaleDirectory, + writeAll, + getStatus, + readAndParseJson, +} = require('./utils') const locales = readLocaleDirectory(true).map((x) => x.replace('.json', '')) const status = getStatus() @@ -12,3 +17,4 @@ module.exports.create = create module.exports.missing = missing module.exports.generate = generate module.exports.writeAll = writeAll +module.exports.readAndParseJson = readAndParseJson diff --git a/packages/locales/lib/missing.js b/packages/locales/lib/missing.js index ed6591b22..bec46ef0b 100644 --- a/packages/locales/lib/missing.js +++ b/packages/locales/lib/missing.js @@ -5,27 +5,34 @@ const { resolve } = require('path') const { log, HELPERS } = require('@rm/logger') const { readAndParseJson, readLocaleDirectory } = require('./utils') -async function missing() { - const localTranslations = readLocaleDirectory(true) +/** + * + * @param {string} fileName + * @returns {Promise} + */ +async function missing(fileName) { const englishRef = await readAndParseJson('en.json', true) + const humanLocales = await readAndParseJson(fileName, true) + /** @type {import('./generate').I18nObject} */ + const missingKeys = {} - await Promise.allSettled( - localTranslations.map(async (fileName) => { - const humanLocales = await readAndParseJson(fileName, true) - const aiLocales = await readAndParseJson(fileName, false) - const combined = { - ...aiLocales, - ...humanLocales, + Object.keys(englishRef) + .sort() + .forEach((key) => { + if (!humanLocales[key] && !key.startsWith('locale_selection_')) { + missingKeys[key] = process.argv.includes('--ally') + ? `t('${key}')` + : englishRef[key] } - const missingKeys = {} + }) + return missingKeys +} - Object.keys(englishRef).forEach((key) => { - if (!combined[key] && !key.startsWith('locale_selection_')) { - missingKeys[key] = process.argv.includes('--ally') - ? `t('${key}')` - : englishRef[key] - } - }) +async function missingAll() { + const localTranslations = readLocaleDirectory(true) + await Promise.allSettled( + localTranslations.map(async (fileName) => { + const missingKeys = await missing(fileName) await fs.writeFile( resolve( __dirname, @@ -44,5 +51,5 @@ async function missing() { module.exports.missing = missing if (require.main === module) { - missing().then(() => process.exit(0)) + missingAll().then(() => process.exit(0)) } diff --git a/server/src/graphql/resolvers.js b/server/src/graphql/resolvers.js index e415a1f21..0b4ad74fa 100644 --- a/server/src/graphql/resolvers.js +++ b/server/src/graphql/resolvers.js @@ -3,6 +3,7 @@ const { resolve } = require('path') const { GraphQLJSON } = require('graphql-type-json') const { S2LatLng, S2RegionCoverer, S2LatLngRect } = require('nodes2ts') const config = require('@rm/config') +const { missing, readAndParseJson } = require('@rm/locales') const buildDefaultFilters = require('../services/filters/builder/base') const filterComponents = require('../services/functions/filterComponents') @@ -198,6 +199,16 @@ const resolvers = { } return {} }, + locales: async (_, { locale }) => { + const missingLocales = await missing(`${locale}.json`) + return locale + ? { + missing: Object.keys(missingLocales), + human: await readAndParseJson(`${locale}.json`, true), + ai: await readAndParseJson(`${locale}.json`, false), + } + : { missing: null, human: null, ai: null } + }, motdCheck: (_, { clientIndex }, { req, perms }) => { const motd = config.getMapConfig(req).messageOfTheDay return ( diff --git a/server/src/graphql/typeDefs/index.graphql b/server/src/graphql/typeDefs/index.graphql index db050f00c..6afeb5b2a 100644 --- a/server/src/graphql/typeDefs/index.graphql +++ b/server/src/graphql/typeDefs/index.graphql @@ -23,6 +23,7 @@ type Query { filters: JSON ): [Gym] gymsSingle(id: ID, perm: String): Gym + locales(locale: String): Locales motdCheck(clientIndex: Int): Boolean nests( minLat: Float diff --git a/server/src/graphql/typeDefs/map.graphql b/server/src/graphql/typeDefs/map.graphql index eb4c59d0e..5840c517e 100644 --- a/server/src/graphql/typeDefs/map.graphql +++ b/server/src/graphql/typeDefs/map.graphql @@ -216,3 +216,9 @@ type ValidUserObj { loggedIn: Boolean admin: Boolean } + +type Locales { + human: JSON + ai: JSON + missing: JSON +} diff --git a/src/assets/css/main.css b/src/assets/css/main.css index f7025d4ae..d5d6cce69 100644 --- a/src/assets/css/main.css +++ b/src/assets/css/main.css @@ -554,3 +554,9 @@ input[type='time']::-webkit-calendar-picker-indicator { opacity: 90%; z-index: 9; } + +.locales-layout { + display: grid; + grid-template-rows: auto 1fr auto; /* Header, table, footer */ + min-height: 100vh; +} diff --git a/src/components/virtual/Table.jsx b/src/components/virtual/Table.jsx new file mode 100644 index 000000000..eacb45b32 --- /dev/null +++ b/src/components/virtual/Table.jsx @@ -0,0 +1,31 @@ +import * as React from 'react' +import Table from '@mui/material/Table' +import TableBody from '@mui/material/TableBody' +import TableContainer from '@mui/material/TableContainer' +import TableHead from '@mui/material/TableHead' +import TableRow from '@mui/material/TableRow' +import Paper from '@mui/material/Paper' +import { TableVirtuoso } from 'react-virtuoso' + +const VirtuosoTableComponents = { + Scroller: React.forwardRef((props, ref) => ( + + )), + Table: (props) => ( + + ), + TableHead, + // eslint-disable-next-line no-unused-vars + TableRow: ({ item: _, ...props }) => , + TableBody: React.forwardRef((props, ref) => ( + + )), +} + +/** @param {import('react-virtuoso').TableVirtuosoProps} props */ +export function VirtualTable(props) { + return +} diff --git a/src/pages/index.jsx b/src/pages/index.jsx index 26c598da4..a425a21be 100644 --- a/src/pages/index.jsx +++ b/src/pages/index.jsx @@ -10,6 +10,7 @@ import { BlockedPage } from './Blocked' import { ErrorPage } from './Error' import { DataManagerPage } from './data' import { ResetPage } from './Reset' +import { LocalesPage } from './locales' const Playground = React.lazy(() => import('./playground').then(({ PlaygroundPage }) => ({ @@ -44,6 +45,7 @@ const playgroundRoute = ( ) const errorRoute = const resetRoute = +const localesPage = export function Pages() { return ( @@ -52,6 +54,7 @@ export function Pages() { + diff --git a/src/pages/locales/components/AllSwitch.jsx b/src/pages/locales/components/AllSwitch.jsx new file mode 100644 index 000000000..677e6c357 --- /dev/null +++ b/src/pages/locales/components/AllSwitch.jsx @@ -0,0 +1,22 @@ +import * as React from 'react' +import FormControlLabel from '@mui/material/FormControlLabel' +import Switch from '@mui/material/Switch' +import { useTranslation } from 'react-i18next' + +import { useLocalesStore } from '../hooks/store' + +export function AllSwitch() { + const { t } = useTranslation() + const all = useLocalesStore((s) => s.all) + return ( + useLocalesStore.setState({ all: checked })} + /> + } + label={t('all')} + /> + ) +} diff --git a/src/pages/locales/components/EditLocale.jsx b/src/pages/locales/components/EditLocale.jsx new file mode 100644 index 000000000..98d299faf --- /dev/null +++ b/src/pages/locales/components/EditLocale.jsx @@ -0,0 +1,34 @@ +// @ts-check +import * as React from 'react' +import TextField from '@mui/material/TextField' +import { useLocalesStore } from '../hooks/store' + +/** @param {{ name: string } & import('@mui/material').TextFieldProps} props */ +export function EditLocale({ name, type, ...props }) { + const value = useLocalesStore((s) => s.custom[name] || '') + const isScrolling = useLocalesStore((s) => s.isScrolling) + /** @type {import('@mui/material').TextFieldProps['onChange']} */ + const onChange = React.useCallback( + (event) => { + useLocalesStore.setState((prev) => ({ + custom: { + ...prev.custom, + [name]: + type === 'number' ? +event.target.value || 0 : event.target.value, + }, + })) + }, + [name], + ) + return ( + + ) +} diff --git a/src/pages/locales/components/LocalesFooter.jsx b/src/pages/locales/components/LocalesFooter.jsx new file mode 100644 index 000000000..f575519da --- /dev/null +++ b/src/pages/locales/components/LocalesFooter.jsx @@ -0,0 +1,35 @@ +// @ts-check +import * as React from 'react' +import Grid from '@mui/material/Unstable_Grid2' +import Button from '@mui/material/Button' +import GitHubIcon from '@mui/icons-material/GitHub' +import { useTranslation } from 'react-i18next' + +import { downloadLocales } from '../hooks/store' +import { AllSwitch } from './AllSwitch' + +const github = + +export function LocalesFooter() { + const { t } = useTranslation() + return ( + + + + + + + + + + + + ) +} diff --git a/src/pages/locales/components/LocalesHeader.jsx b/src/pages/locales/components/LocalesHeader.jsx new file mode 100644 index 000000000..e2ec6e688 --- /dev/null +++ b/src/pages/locales/components/LocalesHeader.jsx @@ -0,0 +1,51 @@ +// @ts-check +import * as React from 'react' +import Grid from '@mui/material/Unstable_Grid2' +import Typography from '@mui/material/Typography' +import Collapse from '@mui/material/Collapse' +import Button from '@mui/material/Button' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { useTranslation } from 'react-i18next' + +import { LocaleSelection } from '@components/inputs/LocaleSelection' + +import { useLocalesStore } from '../hooks/store' + +const expandMore = + +export function LocalesHeader() { + const { t, i18n } = useTranslation() + const instructions = useLocalesStore((s) => s.instructions) + return ( + + + {t('locales')} + + + + + + + + +
    + {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( + + {t(`locale_instructions_${i}`, { lng: i18n.language })} + + ))} +
+
+
+ ) +} diff --git a/src/pages/locales/components/LocalesTable.jsx b/src/pages/locales/components/LocalesTable.jsx new file mode 100644 index 000000000..db23b6dcd --- /dev/null +++ b/src/pages/locales/components/LocalesTable.jsx @@ -0,0 +1,103 @@ +// @ts-check +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import { useQuery } from '@apollo/client' +import TableCell from '@mui/material/TableCell' +import TableRow from '@mui/material/TableRow' +import Box from '@mui/material/Box' + +import { LOCALES_STATUS } from '@services/queries/config' +import { VirtualTable } from '@components/virtual/Table' + +import { setScrolling, useLocalesStore } from '../hooks/store' +import { EditLocale } from './EditLocale' + +/** @type {import('react-virtuoso').TableVirtuosoProps['fixedHeaderContent']} */ +function fixedHeaderContent() { + const { t } = useTranslation() + return ( + + {t('key')} + {t('locale_selection_en')} + {t('ai')} + {t('human')} + + ) +} + +/** @type {import('react-virtuoso').TableVirtuosoProps['itemContent']} */ +function itemContent(_index, row) { + return ( + <> + {row.name} + {row.english} + {row.ai} + + + + + ) +} + +export function LocalesTable() { + const { i18n } = useTranslation() + const all = useLocalesStore((s) => s.all) + + const { data, loading } = useQuery(LOCALES_STATUS, { + fetchPolicy: 'network-only', + nextFetchPolicy: 'cache-only', + variables: { locale: i18n.language }, + }) + + const { data: enData, loading: enLoading } = useQuery(LOCALES_STATUS, { + fetchPolicy: 'network-only', + nextFetchPolicy: 'cache-only', + variables: { locale: 'en' }, + }) + + const stringSorter = new Intl.Collator(i18n.language, { + sensitivity: 'base', + ignorePunctuation: true, + }) + + const rows = React.useMemo(() => { + if (data?.locales && enData?.locales) { + const { missing, ai } = data.locales + /** @type {string[]} */ + const source = all ? Object.keys(enData.locales.human) : missing + return source.toSorted(stringSorter.compare).map((key) => ({ + name: key, + english: enData.locales.human[key], + ai: ai[key], + missing: !!missing[key], + type: typeof enData.locales.human[key], + })) + } + return [] + }, [data, enData, all]) + + React.useEffect(() => { + if (Array.isArray(data?.locales?.missing)) { + useLocalesStore.setState({ + custom: all + ? data?.locales.human + : Object.fromEntries(data.locales.missing.map((key) => [key, ''])), + existingHuman: data.locales.human || {}, + }) + } + }, [data, all]) + + return ( + + + + ) +} diff --git a/src/pages/locales/hooks/store.js b/src/pages/locales/hooks/store.js new file mode 100644 index 000000000..5462b3f0d --- /dev/null +++ b/src/pages/locales/hooks/store.js @@ -0,0 +1,35 @@ +// @ts-check +import { create } from 'zustand' + +import { downloadJson } from '@utils/downloadJson' + +/** + * @typedef {{ + * custom: Record + * existingHuman: Record + * all: boolean + * instructions: boolean + * isScrolling: boolean + * }} LocalesStore + * @type {import("zustand").UseBoundStore>} + */ +export const useLocalesStore = create(() => ({ + custom: {}, + existingHuman: {}, + all: false, + instructions: false, + isScrolling: false, +})) + +export const downloadLocales = () => { + const { custom, existingHuman } = useLocalesStore.getState() + const locale = localStorage.getItem('i18nextLng') || 'en' + const filtered = Object.fromEntries( + Object.entries(custom).filter(([, v]) => v !== ''), + ) + return downloadJson({ ...existingHuman, ...filtered }, `${locale}.json`) +} + +/** @param {boolean} isScrolling */ +export const setScrolling = (isScrolling) => + useLocalesStore.setState({ isScrolling }) diff --git a/src/pages/locales/index.jsx b/src/pages/locales/index.jsx new file mode 100644 index 000000000..1e3921407 --- /dev/null +++ b/src/pages/locales/index.jsx @@ -0,0 +1,20 @@ +// @ts-check +import * as React from 'react' +import Box from '@mui/material/Box' + +import { useHideElement } from '@hooks/useHideElement' + +import { LocalesTable } from './components/LocalesTable' +import { LocalesHeader } from './components/LocalesHeader' +import { LocalesFooter } from './components/LocalesFooter' + +export function LocalesPage() { + useHideElement() + return ( + + + + + + ) +} diff --git a/src/services/queries/config.js b/src/services/queries/config.js index bcfe63d68..da225bec6 100644 --- a/src/services/queries/config.js +++ b/src/services/queries/config.js @@ -51,3 +51,13 @@ export const SAVE_COMPONENT = gql` saveComponent(component: $component, code: $code) } ` + +export const LOCALES_STATUS = gql` + query Locales($locale: String!) { + locales(locale: $locale) { + human + ai + missing + } + } +` diff --git a/src/utils/downloadJson.js b/src/utils/downloadJson.js index 9cbb135e6..cb15218fc 100644 --- a/src/utils/downloadJson.js +++ b/src/utils/downloadJson.js @@ -9,7 +9,9 @@ export function downloadJson(json, fileName) { const el = document.createElement('a') el.setAttribute( 'href', - `data:application/json;charset=utf-8,${encodeURIComponent(json)}`, + `data:application/json;charset=utf-8,${encodeURIComponent( + typeof json === 'string' ? json : JSON.stringify(json, null, 2), + )}`, ) el.setAttribute('download', fileName) el.style.display = 'none'