Skip to content

Commit 75ab5da

Browse files
authored
feat(i18n): expand coverage and harden locale handling (follow-up to #1765) (#1766)
Resolve outstanding translation gaps and PR-review findings on top of the initial i18n landing. Locale infrastructure - Add LocaleContext/LocaleProvider so language changes from the dropdown apply immediately without a page refresh. - Memoize the provider's context value (useMemo + useCallback) and reset the explicit-locale flag when user.id changes so cookie/navigator fallbacks are not permanently shadowed. - Wire date-fns locale helpers through useDateFnsLocale across history, command, server-stat, and report components. Translation completeness - Translate every remaining English string surfaced in the bug reports: homepage, dashboard, dashboard/appeals, player pages, notifications, account/email, account/password, and the entire admin panel (servers, roles, webhooks, notification rules, documents, etc.). - Add German messages for ~470 new keys and keep en.json/de.json at full key parity (562 keys each). Translatability quality - Replace sentence-splitting with single rich-text ICU keys that embed React components (<actor>, <punisher>, <player>, <badge>, <path>, <docs>) for the report header, appeal description, comment timestamps, punishment update messages, and push-notification setup steps. - Use ICU select for permanent vs. temporary appeal grammar and ICU plural for player counts (active/past bans, mutes, warnings, notes, kicks, report records). - Collapse concatenated page titles for account/email, account/password, and admin/servers/[id] into single parameterized keys. - Standardize on common.permanent (drop duplicate pages.punishment.permanent) and rename actions.warn -> actions.warning to match the GraphQL type. Bug fixes - Fix t.rich rendering in PushNotificationButton by using rich-text tag syntax and a chunks-based render function instead of a placeholder. - Drop the default-arg t() call in CommentWithUpload so t() is only invoked when no placeholder prop is supplied. - Restore unrelated comments accidentally removed from ServerForm. Backend - Localize adminNavigation labels via the resolver and expose the locale on User type for client-side reconciliation.
1 parent 777e791 commit 75ab5da

117 files changed

Lines changed: 2006 additions & 629 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

components/AdminLayout.js

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Head from 'next/head'
22
import Link from 'next/link'
33
import { useRouter, withRouter } from 'next/router'
4+
import { useTranslations } from 'next-intl'
45
import clsx from 'clsx'
56
import { BiServer } from 'react-icons/bi'
67
import { MdOutlineGroups, MdOutlineExitToApp, MdOutlineNotifications, MdLogout, MdSettings, MdWebhook, MdOutlineImage } from 'react-icons/md'
@@ -12,21 +13,24 @@ import SessionNavProfile from './SessionNavProfile'
1213
import { useApi, useUser } from '../utils'
1314

1415
const icons = {
15-
Documents: <MdOutlineImage />,
16-
Roles: <MdOutlineGroups />,
17-
Servers: <BiServer />,
18-
'Notification Rules': <MdOutlineNotifications />,
19-
Webhooks: <MdWebhook />
16+
documents: <MdOutlineImage />,
17+
roles: <MdOutlineGroups />,
18+
servers: <BiServer />,
19+
notificationRules: <MdOutlineNotifications />,
20+
webhooks: <MdWebhook />
2021
}
2122

2223
const AdminLayout = ({ children, title }) => {
24+
const t = useTranslations()
25+
const tNav = useTranslations('pages.admin.nav')
2326
const router = useRouter()
2427
const { user } = useUser({ redirectIfFound: false, redirectTo: '/login' })
2528
const { loading, data, errors } = useApi({
2629
query: `query navigation {
2730
adminNavigation {
2831
left {
2932
id
33+
key
3034
name
3135
href
3236
label
@@ -39,10 +43,13 @@ const AdminLayout = ({ children, title }) => {
3943
if (errors || !data) return <ErrorLayout errors={errors} />
4044

4145
const right = [<SessionNavProfile key='session-nav-profile' user={user} />]
42-
const left = data.adminNavigation.left
46+
const left = data.adminNavigation.left.map(item => ({
47+
...item,
48+
name: item.key ? safeTranslate(tNav, item.key, item.name) : item.name
49+
}))
4350
const mobileItems = left.slice()
4451

45-
mobileItems[mobileItems.length - 1].splitBorder = true
52+
if (mobileItems.length) mobileItems[mobileItems.length - 1].splitBorder = true
4653

4754
return (
4855
<>
@@ -59,7 +66,7 @@ const AdminLayout = ({ children, title }) => {
5966
<div className='pt-6 ml-8'>
6067
<div className='font-bold text-xl flex w-full'>
6168
<span className='mx-4'>
62-
<Link href='/admin'>Admin</Link>
69+
<Link href='/admin'>{t('pages.admin.title')}</Link>
6370
</span>
6471
<Link href='/dashboard' passHref className='m-auto flex-grow text-right'>
6572
<MdOutlineExitToApp className='inline-flex mx-5 hover:text-accent-200' />
@@ -68,15 +75,15 @@ const AdminLayout = ({ children, title }) => {
6875
</div>
6976
<nav className='mt-6 h-screen flex flex-col justify-between'>
7077
<div className='h-full'>
71-
{left.map(({ href, name, label }) => {
78+
{left.map(({ href, name, key, label }) => {
7279
const className = clsx('hover:text-accent-200 flex transition-colors text-gray-100 text-xl p-2 my-4', {
7380
'border-l-4 border-accent-500': router.asPath === href
7481
})
7582
return (
7683
(
7784
<Link key={`${href}${name}`} href={href} passHref className={className}>
7885

79-
{icons[name] && <span className='text-left m-auto'>{icons[name]}</span>}
86+
{icons[key] && <span className='text-left m-auto'>{icons[key]}</span>}
8087
<span className='mx-4 text-base m-auto font-normal'>
8188
{name}
8289
</span>
@@ -102,24 +109,24 @@ const AdminLayout = ({ children, title }) => {
102109
<Avatar width='36' height='36' uuid={user.id} />
103110
<div className='ml-4 text-sm'>
104111
<div>{user.name}</div>
105-
<div>View Profile</div>
112+
<div>{t('pages.admin.layout.viewProfile')}</div>
106113
</div>
107114

108115
</Link>
109116
</div>
110117
</div>
111118
<div className='flex justify-evenly py-3 bg-gray-800'>
112-
<Link href='/notifications' title='Notifications'>
119+
<Link href='/notifications' title={t('pages.admin.layout.notifications')}>
113120

114121
<MdOutlineNotifications className='w-6 h-6' />
115122

116123
</Link>
117-
<Link href='/account' title='Settings'>
124+
<Link href='/account' title={t('pages.admin.layout.settings')}>
118125

119126
<MdSettings className='w-6 h-6' />
120127

121128
</Link>
122-
<Link href='/dashboard' title='Return'>
129+
<Link href='/dashboard' title={t('pages.admin.layout.return')}>
123130

124131
<MdLogout className='w-6 h-6' />
125132

@@ -143,4 +150,16 @@ const AdminLayout = ({ children, title }) => {
143150
)
144151
}
145152

153+
function safeTranslate (translator, key, fallback) {
154+
if (typeof translator?.has === 'function' && !translator.has(key)) return fallback
155+
156+
try {
157+
const value = translator(key)
158+
159+
return value === key ? fallback : value
160+
} catch (_) {
161+
return fallback
162+
}
163+
}
164+
146165
export default withRouter(AdminLayout)

components/CommentWithUpload.js

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import { useState, useRef, useCallback, forwardRef, createContext, useContext, u
33
import Uploady, { useUploady, useBatchAddListener, useItemProgressListener, useItemFinishListener, useItemErrorListener } from '@rpldy/uploady'
44
import { usePasteUpload } from '@rpldy/upload-paste'
55
import { FiPaperclip, FiX } from 'react-icons/fi'
6+
import { useTranslations } from 'next-intl'
67
import clsx from 'clsx'
78

89
const UploadContext = createContext(null)
910

1011
function FileChip ({ id, url, name, onRemove, progress, error }) {
12+
const t = useTranslations('widgets.upload')
1113
const isUploading = progress !== undefined && progress < 100
1214
const displayName = name.length > 20 ? name.slice(0, 17) + '...' : name
1315

@@ -37,7 +39,7 @@ function FileChip ({ id, url, name, onRemove, progress, error }) {
3739
error ? 'text-red-400' : 'text-gray-300'
3840
)}
3941
>
40-
{error ? 'Failed' : isUploading ? 'Uploading...' : displayName}
42+
{error ? t('failed') : isUploading ? t('uploading') : displayName}
4143
</span>
4244

4345
{/* Delete button with proper touch target */}
@@ -49,7 +51,7 @@ function FileChip ({ id, url, name, onRemove, progress, error }) {
4951
'text-gray-400 hover:text-white hover:bg-red-500/80'
5052
)}
5153
type='button'
52-
title='Remove'
54+
title={t('remove')}
5355
>
5456
<FiX className='w-4 h-4' />
5557
</button>
@@ -68,6 +70,7 @@ function FileChip ({ id, url, name, onRemove, progress, error }) {
6870
}
6971

7072
export function AttachButton ({ disabled }) {
73+
const t = useTranslations('widgets.upload')
7174
const ctx = useContext(UploadContext)
7275
if (!ctx) return null
7376

@@ -89,8 +92,8 @@ export function AttachButton ({ disabled }) {
8992
)}
9093
>
9194
<FiPaperclip className='w-4 h-4 flex-shrink-0' />
92-
<span className='lg:hidden'>Add files</span>
93-
<span className='hidden lg:inline'>Paste, drop, or click to add files</span>
95+
<span className='lg:hidden'>{t('addFiles')}</span>
96+
<span className='hidden lg:inline'>{t('addFilesLong')}</span>
9497
</button>
9598
{maxFiles > 0 && totalFiles > 0 && (
9699
<span className='text-xs text-gray-500'>
@@ -102,11 +105,12 @@ export function AttachButton ({ disabled }) {
102105
}
103106

104107
const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) {
108+
const t = useTranslations('widgets.upload')
105109
const {
106110
onDocumentsChange,
107111
documents = [],
108112
maxFiles = 3,
109-
placeholder = 'Add your comment here...',
113+
placeholder,
110114
maxLength = 250,
111115
minLength,
112116
required = false,
@@ -119,6 +123,7 @@ const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) {
119123
children,
120124
...rest
121125
} = props
126+
const resolvedPlaceholder = placeholder ?? t('commentPlaceholder')
122127

123128
const containerRef = useRef(null)
124129
const uploady = useUploady()
@@ -175,7 +180,7 @@ const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) {
175180
})
176181

177182
useItemErrorListener((item) => {
178-
let errorMessage = 'Upload failed'
183+
let errorMessage = t('uploadFailed')
179184
try {
180185
const response = item.uploadResponse?.data
181186
if (response?.error) {
@@ -283,7 +288,7 @@ const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) {
283288
ref={ref}
284289
value={value}
285290
onChange={onChange}
286-
placeholder={placeholder}
291+
placeholder={resolvedPlaceholder}
287292
maxLength={maxLength}
288293
minLength={minLength}
289294
required={required}
@@ -305,7 +310,7 @@ const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) {
305310
key={docId}
306311
id={docId}
307312
url={`${process.env.BASE_PATH || ''}/api/documents/${docId}`}
308-
name={`Image ${idx + 1}`}
313+
name={t('imageName', { index: idx + 1 })}
309314
onRemove={handleRemoveUploaded}
310315
/>
311316
))}
@@ -323,7 +328,7 @@ const TextAreaWithUpload = forwardRef(function TextAreaWithUpload (props, ref) {
323328

324329
{isDragging && (
325330
<div className='absolute inset-0 flex items-center justify-center bg-accent-500/20 rounded-3xl pointer-events-none'>
326-
<span className='text-accent-400 font-medium'>Drop images here</span>
331+
<span className='text-accent-400 font-medium'>{t('dropImages')}</span>
327332
</div>
328333
)}
329334
</div>

components/DocumentGallery.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/* eslint-disable @next/next/no-img-element */
22
import { useState, useEffect } from 'react'
33
import { FiTrash2 } from 'react-icons/fi'
4+
import { useTranslations } from 'next-intl'
45
import Modal from './Modal'
56
import { useMutateApi } from '../utils'
67

78
function DocumentItem ({ document, onDelete, canDelete }) {
9+
const t = useTranslations()
810
const [confirmDelete, setConfirmDelete] = useState(false)
911
const { load, loading, data, errors } = useMutateApi({
1012
query: `mutation deleteDocument($id: ID!) {
@@ -50,23 +52,23 @@ function DocumentItem ({ document, onDelete, canDelete }) {
5052
data-cy='document-delete'
5153
className='absolute top-2 right-2 p-1.5 bg-black/60 hover:bg-red-600 rounded-md text-white opacity-0 group-hover:opacity-100 transition-opacity'
5254
onClick={() => setConfirmDelete(true)}
53-
title='Delete image'
55+
title={t('pages.admin.documents.deleteImage')}
5456
>
5557
<FiTrash2 className='w-4 h-4' />
5658
</button>
5759
)}
5860
</div>
5961

6062
<Modal
61-
title='Delete Image'
62-
confirmButton='Delete'
63+
title={t('pages.admin.documents.deleteImageTitle')}
64+
confirmButton={t('common.delete')}
6365
open={confirmDelete}
6466
onConfirm={handleDelete}
6567
onCancel={() => setConfirmDelete(false)}
6668
loading={loading}
6769
>
68-
<p className='pb-1'>Are you sure you want to delete this image?</p>
69-
<p className='pb-1 text-gray-400'>This action cannot be undone.</p>
70+
<p className='pb-1'>{t('pages.admin.documents.deleteImageConfirm')}</p>
71+
<p className='pb-1 text-gray-400'>{t('pages.punishment.actionUndoable')}</p>
7072
{errors && <p className='text-red-500 text-sm'>{errors[0]?.message}</p>}
7173
</Modal>
7274
</>

components/ErrorLayout.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1+
import { useTranslations } from 'next-intl'
12
import DefaultLayout from './DefaultLayout'
23
import ErrorMessages from './ErrorMessages'
34
import PageContainer from './PageContainer'
45
import PageHeader from './PageHeader'
56
import Panel from './Panel'
67

78
export default function ErrorLayout ({ errors }) {
9+
const t = useTranslations('errors')
10+
811
return (
9-
<DefaultLayout title='Error'>
12+
<DefaultLayout title={t('header')}>
1013
<PageContainer>
1114
<Panel className='mx-auto w-full max-w-md'>
12-
<PageHeader subTitle='Error' title='Something went wrong' />
15+
<PageHeader subTitle={t('header')} title={t('somethingWentWrong')} />
1316
<ErrorMessages errors={errors} />
1417
</Panel>
1518
</PageContainer>

components/LanguageSwitcher.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useState } from 'react'
2-
import { useRouter } from 'next/router'
32
import { useTranslations } from 'next-intl'
43
import { mutate } from 'swr'
54
import { MdLanguage } from 'react-icons/md'
@@ -10,8 +9,7 @@ import {
109
LOCALE_CONFIG,
1110
SUPPORTED_LOCALES,
1211
isSupportedLocale,
13-
useResolvedLocale,
14-
writeLocaleCookie
12+
useResolvedLocale
1513
} from '../utils/locale'
1614

1715
const setLocaleMutation = `
@@ -25,9 +23,8 @@ const setLocaleMutation = `
2523

2624
export default function LanguageSwitcher ({ buttonClassName = '', variant = 'icon' }) {
2725
const t = useTranslations('widgets.languageSwitcher')
28-
const router = useRouter()
2926
const { user } = useUser()
30-
const { locale } = useResolvedLocale()
27+
const { locale, setLocale } = useResolvedLocale()
3128
const [updating, setUpdating] = useState(false)
3229

3330
const persistRemoteLocale = async (next) => {
@@ -56,7 +53,7 @@ export default function LanguageSwitcher ({ buttonClassName = '', variant = 'ico
5653
if (!isSupportedLocale(next) || next === locale || updating) return
5754

5855
setUpdating(true)
59-
writeLocaleCookie(next)
56+
setLocale(next)
6057

6158
try {
6259
if (user?.id) {
@@ -66,7 +63,6 @@ export default function LanguageSwitcher ({ buttonClassName = '', variant = 'ico
6663
} catch (err) {
6764
console.error('Failed to persist locale preference', err)
6865
} finally {
69-
router.replace(router.asPath, undefined, { scroll: false })
7066
setUpdating(false)
7167
}
7268
}

components/NavigationOverlay.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createContext } from 'react'
22
import { Transition } from '@headlessui/react'
33
import { RemoveScroll } from 'react-remove-scroll'
4+
import { useTranslations } from 'next-intl'
45
import clsx from 'clsx'
56

67
const NavigationOverlayContext = createContext()
@@ -37,6 +38,8 @@ const NavigationOverlay = ({ children, drawerOpen, setDrawerOpen }) => {
3738
}
3839

3940
const Header = ({ children }) => {
41+
const t = useTranslations('nav')
42+
4043
return (
4144
<NavigationOverlayContext.Consumer>
4245
{({ setDrawerOpen }) => (
@@ -47,7 +50,7 @@ const Header = ({ children }) => {
4750
</div>
4851
<div className='-mr-2'>
4952
<button type='button' className='rounded-md p-2 inline-flex items-center justify-center text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none' onClick={() => setDrawerOpen(false)}>
50-
<span className='sr-only'>Close menu</span>
53+
<span className='sr-only'>{t('closeMenu')}</span>
5154
<svg className='h-6 w-6' xmlns='http:www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor' aria-hidden='true'>
5255
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
5356
</svg>

0 commit comments

Comments
 (0)