Skip to content

Commit a673bfb

Browse files
committed
WIP: Translation editor changes
1 parent 14073aa commit a673bfb

File tree

4 files changed

+369
-6
lines changed

4 files changed

+369
-6
lines changed

pages/nodes/[nodeId]/i18n.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
NodeTranslations,
1818
CreateNodeTranslationsBody,
1919
} from '@/src/api/generated'
20-
import { withAuth } from '@/components/withAuth'
20+
import withAuth from '@/components/common/HOC/withAuth'
2121

2222
const NodeTranslationEditor = () => {
2323
const router = useRouter()
@@ -272,9 +272,7 @@ const NodeTranslationEditor = () => {
272272

273273
{field === 'description' ? (
274274
<Textarea
275-
value={
276-
currentTranslations[field] || ''
277-
}
275+
value={currentTranslations[field] || ''}
278276
onChange={(e) =>
279277
updateTranslation(field, e.target.value)
280278
}
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
import { useState, useMemo } from 'react'
2+
import { useRouter } from 'next/router'
3+
import {
4+
Breadcrumb,
5+
Card,
6+
Button,
7+
TextInput,
8+
Textarea,
9+
Select,
10+
Alert,
11+
} from 'flowbite-react'
12+
import { HiHome, HiSave, HiPlus, HiTrash } from 'react-icons/hi'
13+
import { useNextTranslation } from '@/src/hooks/i18n'
14+
import { useGetNode, useCreateNodeTranslations } from '@/src/api/generated'
15+
import { SUPPORTED_LANGUAGES } from '@/src/constants'
16+
import type {
17+
NodeTranslations,
18+
CreateNodeTranslationsBody,
19+
} from '@/src/api/generated'
20+
import withAuth from '@/components/common/HOC/withAuth'
21+
22+
export default withAuth(NodeTranslationEditor)
23+
24+
function NodeTranslationEditor() {
25+
const router = useRouter()
26+
const { nodeId } = router.query as { nodeId: string }
27+
const { t, currentLanguage } = useNextTranslation()
28+
29+
const [selectedLanguage, setSelectedLanguage] = useState<string>('en')
30+
const [translations, setTranslations] = useState<NodeTranslations>({})
31+
const [newFieldKey, setNewFieldKey] = useState('')
32+
const [successMessage, setSuccessMessage] = useState('')
33+
const [errorMessage, setErrorMessage] = useState('')
34+
35+
// Memoize display names to avoid recreating Intl.DisplayNames instances on every render
36+
const displayNames = useMemo(() => {
37+
const currentLangDisplayNames = new Intl.DisplayNames(
38+
[currentLanguage],
39+
{
40+
type: 'language',
41+
}
42+
)
43+
44+
return SUPPORTED_LANGUAGES.reduce(
45+
(acc, langCode) => {
46+
const thatLangDisplayNames = new Intl.DisplayNames([langCode], {
47+
type: 'language',
48+
})
49+
50+
acc[langCode] = {
51+
nameInMyLanguage: currentLangDisplayNames.of(langCode),
52+
nameInThatLanguage: thatLangDisplayNames.of(langCode),
53+
}
54+
return acc
55+
},
56+
{} as Record<
57+
string,
58+
{ nameInMyLanguage?: string; nameInThatLanguage?: string }
59+
>
60+
)
61+
}, [currentLanguage])
62+
63+
const {
64+
data: node,
65+
isLoading,
66+
error,
67+
} = useGetNode(nodeId, { include_translations: true })
68+
69+
const createTranslationsMutation = useCreateNodeTranslations()
70+
// {[locale]: {[text]: 'translation'}}
71+
const existingTranslations = node?.translations || {}
72+
73+
const handleLanguageChange = (language: string) => {
74+
setSelectedLanguage(language)
75+
if (!translations[language] && !existingTranslations[language]) {
76+
setTranslations({
77+
...translations,
78+
[language]: {},
79+
})
80+
}
81+
}
82+
83+
const getCurrentTranslations = () => {
84+
return {
85+
...existingTranslations[selectedLanguage],
86+
...translations[selectedLanguage],
87+
} as Record<string, unknown>
88+
}
89+
90+
const updateTranslation = (field: string, value: string) => {
91+
setTranslations({
92+
...translations,
93+
[selectedLanguage]: {
94+
...translations[selectedLanguage],
95+
[field]: value,
96+
},
97+
})
98+
}
99+
100+
const addNewField = () => {
101+
if (!newFieldKey.trim()) return
102+
103+
updateTranslation(newFieldKey, '')
104+
setNewFieldKey('')
105+
}
106+
107+
const removeField = (field: string) => {
108+
const updatedLangTranslations = { ...translations[selectedLanguage] }
109+
delete updatedLangTranslations[field]
110+
111+
setTranslations({
112+
...translations,
113+
[selectedLanguage]: updatedLangTranslations,
114+
})
115+
}
116+
117+
const saveTranslations = async () => {
118+
if (!nodeId || !translations[selectedLanguage]) return
119+
120+
const translationData: CreateNodeTranslationsBody = {
121+
data: {
122+
[selectedLanguage]: translations[selectedLanguage],
123+
},
124+
}
125+
126+
try {
127+
await createTranslationsMutation.mutateAsync({
128+
nodeId: nodeId as string,
129+
data: translationData,
130+
})
131+
setSuccessMessage(t('Translation saved successfully!'))
132+
setErrorMessage('')
133+
setTimeout(() => setSuccessMessage(''), 3000)
134+
} catch (err) {
135+
setErrorMessage(t('Failed to save translation'))
136+
setSuccessMessage('')
137+
console.error('Translation save error:', err)
138+
}
139+
}
140+
141+
if (isLoading) {
142+
return (
143+
<div className="p-4">
144+
<div className="animate-pulse">
145+
<div className="h-8 bg-gray-300 rounded mb-4"></div>
146+
<div className="h-64 bg-gray-300 rounded"></div>
147+
</div>
148+
</div>
149+
)
150+
}
151+
152+
if (error || !node) {
153+
return (
154+
<div className="p-4">
155+
<Alert color="failure">{t('Failed to load node data')}</Alert>
156+
</div>
157+
)
158+
}
159+
160+
const currentTranslations = getCurrentTranslations()
161+
const allFields = new Set([
162+
'name',
163+
'description',
164+
'category',
165+
'tags',
166+
...Object.keys(currentTranslations),
167+
])
168+
169+
return (
170+
<div className="p-4 max-w-4xl mx-auto">
171+
<div className="py-4">
172+
<Breadcrumb>
173+
<Breadcrumb.Item
174+
href="/"
175+
icon={HiHome}
176+
onClick={(e) => {
177+
e.preventDefault()
178+
router.push('/')
179+
}}
180+
className="dark"
181+
>
182+
{t('Home')}
183+
</Breadcrumb.Item>
184+
<Breadcrumb.Item className="dark">
185+
{t('All Nodes')}
186+
</Breadcrumb.Item>
187+
<Breadcrumb.Item
188+
href={`/nodes/${nodeId}`}
189+
onClick={(e) => {
190+
e.preventDefault()
191+
router.push(`/nodes/${nodeId}`)
192+
}}
193+
className="dark"
194+
>
195+
{node.name || (nodeId as string)}
196+
</Breadcrumb.Item>
197+
<Breadcrumb.Item className="dark text-blue-500">
198+
{t('Translations')}
199+
</Breadcrumb.Item>
200+
</Breadcrumb>
201+
</div>
202+
203+
<div className="mb-6">
204+
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
205+
{t('Node Translations')}
206+
</h1>
207+
<p className="text-gray-600 dark:text-gray-400">
208+
{t('Manage translations for')}: <strong>{node.name}</strong>
209+
</p>
210+
</div>
211+
212+
{successMessage && (
213+
<Alert color="success" className="mb-4">
214+
{successMessage}
215+
</Alert>
216+
)}
217+
218+
{errorMessage && (
219+
<Alert color="failure" className="mb-4">
220+
{errorMessage}
221+
</Alert>
222+
)}
223+
<Card>
224+
<div className="mb-4">
225+
<label
226+
htmlFor="language-select"
227+
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
228+
>
229+
{t('Select Language')}
230+
</label>
231+
<Select
232+
id="language-select"
233+
value={selectedLanguage}
234+
onChange={(e) => handleLanguageChange(e.target.value)}
235+
>
236+
{SUPPORTED_LANGUAGES.map((lang) => {
237+
const { nameInMyLanguage, nameInThatLanguage } =
238+
displayNames[lang]
239+
return (
240+
<option key={lang} value={lang}>
241+
{nameInThatLanguage} ({nameInMyLanguage})
242+
</option>
243+
)
244+
})}
245+
</Select>
246+
</div>
247+
248+
{JSON.stringify(existingTranslations)}
249+
250+
<div className="mb-4 border-b border-gray-200 dark:border-gray-700 pb-4">
251+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
252+
{t('Add New Field')}
253+
</h3>
254+
<div className="flex gap-2">
255+
<TextInput
256+
placeholder={t(
257+
'Field name (e.g., description, category)'
258+
)}
259+
value={newFieldKey}
260+
onChange={(e) => setNewFieldKey(e.target.value)}
261+
className="flex-1"
262+
/>
263+
<Button
264+
onClick={addNewField}
265+
disabled={!newFieldKey.trim()}
266+
size="sm"
267+
>
268+
<HiPlus className="mr-2 h-4 w-4" />
269+
{t('Add')}
270+
</Button>
271+
</div>
272+
</div>
273+
274+
<div className="space-y-4">
275+
{Array.from(allFields).map((field) => (
276+
<div
277+
key={field}
278+
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
279+
>
280+
<div className="flex justify-between items-start mb-2">
281+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
282+
{field}
283+
</label>
284+
{![
285+
'name',
286+
'description',
287+
'category',
288+
'tags',
289+
].includes(field) && (
290+
<Button
291+
size="xs"
292+
color="failure"
293+
onClick={() => removeField(field)}
294+
>
295+
<HiTrash className="h-3 w-3" />
296+
</Button>
297+
)}
298+
</div>
299+
300+
{field === 'description' ? (
301+
<Textarea
302+
value={
303+
(currentTranslations[
304+
field
305+
] as string) || ''
306+
}
307+
onChange={(e) =>
308+
updateTranslation(field, e.target.value)
309+
}
310+
rows={3}
311+
placeholder={t(
312+
`Enter ${field} translation...`
313+
)}
314+
/>
315+
) : (
316+
<TextInput
317+
value={
318+
(currentTranslations[
319+
field
320+
] as string) || ''
321+
}
322+
onChange={(e) =>
323+
updateTranslation(field, e.target.value)
324+
}
325+
placeholder={t(
326+
`Enter ${field} translation...`
327+
)}
328+
/>
329+
)}
330+
331+
{existingTranslations[selectedLanguage]?.[
332+
field
333+
] && (
334+
<p className="text-xs text-gray-500 mt-1">
335+
{t('Original')}:{' '}
336+
{String(
337+
existingTranslations[selectedLanguage][
338+
field
339+
]
340+
)}
341+
</p>
342+
)}
343+
</div>
344+
))}
345+
</div>
346+
347+
<div className="mt-6 flex justify-end">
348+
<Button
349+
onClick={saveTranslations}
350+
disabled={
351+
createTranslationsMutation.isPending ||
352+
!translations[selectedLanguage]
353+
}
354+
size="lg"
355+
>
356+
<HiSave className="mr-2 h-5 w-5" />
357+
{createTranslationsMutation.isPending
358+
? t('Saving...')
359+
: t('Save Translations')}
360+
</Button>
361+
</div>
362+
</Card>
363+
</div>
364+
)
365+
}

src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const LANGUAGE_STORAGE_KEY =
1515
// Language configuration
1616
export const DEFAULT_LANGUAGE = 'en'
1717

18-
const LANGUAGE_NAMES = {
18+
export const LANGUAGE_NAMES = {
1919
en: 'English',
2020
zh: '中文',
2121
ja: '日本語',

0 commit comments

Comments
 (0)