201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import Link from '@/framework/link'
|
|
import { useParams } from '@/framework/navigation'
|
|
import {
|
|
Button,
|
|
Container,
|
|
Group,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
Title,
|
|
} from '@pikku/mantine/core'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { asI18n } from '@pikku/react'
|
|
import type {
|
|
BackendTranslationBundle,
|
|
BackendTranslationNamespace,
|
|
} from '@heygermany/sdk'
|
|
import { invokeRPC } from '@/pikku/rpc'
|
|
|
|
function toPrettyJson(value: Record<string, unknown>) {
|
|
return JSON.stringify(value, null, 2)
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
export default function BackOfficeTranslationsPage() {
|
|
const params = useParams()
|
|
const lang = params.lang as string
|
|
const queryClient = useQueryClient()
|
|
const [selectedLocale, setSelectedLocale] = useState<string | null>(null)
|
|
const [selectedNamespace, setSelectedNamespace] =
|
|
useState<BackendTranslationNamespace | null>(null)
|
|
const [editorValue, setEditorValue] = useState('')
|
|
const [editorError, setEditorError] = useState<string | null>(null)
|
|
|
|
const query = useQuery({
|
|
queryKey: ['backoffice', 'translations'],
|
|
queryFn: async () =>
|
|
await invokeRPC<BackendTranslationBundle[]>('getBackOfficeTranslations', {}),
|
|
})
|
|
|
|
const rows = query.data ?? []
|
|
|
|
const localeOptions = useMemo(
|
|
() => Array.from(new Set(rows.map((row) => row.locale))).map((locale) => ({
|
|
value: locale,
|
|
label: locale,
|
|
})),
|
|
[rows]
|
|
)
|
|
|
|
const namespaceOptions = useMemo(
|
|
() => Array.from(new Set(rows.map((row) => row.namespace))).map((namespace) => ({
|
|
value: namespace,
|
|
label: namespace,
|
|
})),
|
|
[rows]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (rows.length === 0) return
|
|
|
|
if (!selectedLocale) {
|
|
setSelectedLocale(rows.find((row) => Object.keys(row.content).length > 0)?.locale ?? rows[0].locale)
|
|
}
|
|
|
|
if (!selectedNamespace) {
|
|
setSelectedNamespace(
|
|
rows.find((row) => Object.keys(row.content).length > 0)?.namespace ?? rows[0].namespace
|
|
)
|
|
}
|
|
}, [rows, selectedLocale, selectedNamespace])
|
|
|
|
const selectedRow = useMemo(
|
|
() =>
|
|
rows.find(
|
|
(row) =>
|
|
row.locale === selectedLocale && row.namespace === selectedNamespace
|
|
) ?? null,
|
|
[rows, selectedLocale, selectedNamespace]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!selectedRow) return
|
|
setEditorValue(toPrettyJson(selectedRow.content))
|
|
setEditorError(null)
|
|
}, [selectedRow])
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
if (!selectedLocale || !selectedNamespace) {
|
|
throw new Error('Select a locale and namespace first')
|
|
}
|
|
|
|
const parsed = JSON.parse(editorValue)
|
|
if (!isRecord(parsed)) {
|
|
throw new Error('Translation bundle must be a JSON object')
|
|
}
|
|
|
|
return await invokeRPC<BackendTranslationBundle>('updateBackOfficeTranslation', {
|
|
locale: selectedLocale,
|
|
namespace: selectedNamespace,
|
|
content: parsed,
|
|
})
|
|
},
|
|
onSuccess: async () => {
|
|
setEditorError(null)
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ['backoffice', 'translations'],
|
|
})
|
|
},
|
|
onError: (error) => {
|
|
setEditorError(error instanceof Error ? error.message : String(error))
|
|
},
|
|
})
|
|
|
|
return (
|
|
<Container size="xl">
|
|
<Stack gap="lg">
|
|
<Group justify="space-between" align="flex-start">
|
|
<Stack gap={4}>
|
|
<Title order={1}>{asI18n('Backend translations')}</Title>
|
|
<Text c="dimmed">
|
|
{asI18n('Edit DB-backed candidate result copy. Changes apply live to backend result rendering.')}
|
|
</Text>
|
|
</Stack>
|
|
<Button component={Link} href={`/${lang}/backoffice/candidates`} variant="light">
|
|
{asI18n('Back to candidates')}
|
|
</Button>
|
|
</Group>
|
|
|
|
<Paper withBorder radius="md" p="lg">
|
|
<Stack gap="md">
|
|
<Group grow align="flex-start">
|
|
<Select
|
|
label={asI18n('Locale')}
|
|
data={localeOptions}
|
|
value={selectedLocale}
|
|
onChange={(value) => setSelectedLocale(value)}
|
|
searchable
|
|
/>
|
|
<Select
|
|
label={asI18n('Namespace')}
|
|
data={namespaceOptions}
|
|
value={selectedNamespace}
|
|
onChange={(value) =>
|
|
setSelectedNamespace(value as BackendTranslationNamespace | null)
|
|
}
|
|
/>
|
|
</Group>
|
|
|
|
<Textarea
|
|
label={asI18n('Translation JSON')}
|
|
autosize
|
|
minRows={24}
|
|
maxRows={40}
|
|
value={editorValue}
|
|
onChange={(event) => setEditorValue(event.currentTarget.value)}
|
|
placeholder={asI18n('{ }')}
|
|
/>
|
|
|
|
{selectedRow ? (
|
|
<Text size="sm" c="dimmed">
|
|
{asI18n(`Last updated: ${new Date(selectedRow.updatedAt).toLocaleString()}`)}
|
|
</Text>
|
|
) : null}
|
|
|
|
{editorError ? (
|
|
<Text size="sm" c="red">
|
|
{asI18n(editorError)}
|
|
</Text>
|
|
) : null}
|
|
|
|
<Group justify="space-between">
|
|
<Text size="sm" c="dimmed">
|
|
{query.isLoading
|
|
? asI18n('Loading translations...')
|
|
: asI18n(`${rows.length} translation bundles available`)}
|
|
</Text>
|
|
<Button
|
|
loading={saveMutation.isPending}
|
|
disabled={!selectedRow}
|
|
onClick={() => saveMutation.mutate()}
|
|
>
|
|
{asI18n('Save translation bundle')}
|
|
</Button>
|
|
</Group>
|
|
</Stack>
|
|
</Paper>
|
|
</Stack>
|
|
</Container>
|
|
)
|
|
}
|