feat(react-ui): localize models and fix 'Import' typo (#10341)

* feat(react-ui): localize SearchableSelect component

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* feat(react-ui): localize ModelSelector component

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* fix(react-ui): dynamically localize back navigation caption to match page title

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* feat(react-ui): localize back navigation state on Models page

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* feat(react-ui): localize ModelEditor page

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

* fix(react-ui): fix Indonesian typo 'Import' to 'Impor' in importModel locale

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>

---------

Signed-off-by: Dedy F. Setyawan <dedyfajars@gmail.com>
Co-authored-by: Ettore Di Giacinto <mudler@users.noreply.github.com>
This commit is contained in:
Dedy F. Setyawan
2026-06-15 23:26:27 +07:00
committed by GitHub
parent 51c23197ed
commit 9ba8521e7e
13 changed files with 136 additions and 38 deletions

View File

@@ -44,7 +44,7 @@ test.describe('Model Editor — Back navigation', () => {
await mockEditorEndpoints(page)
})
test('Back returns to Manage with a "Back to Manage" caption', async ({ page }) => {
test('Back returns to Manage with a "Back to System" caption', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
@@ -55,7 +55,7 @@ test.describe('Model Editor — Back navigation', () => {
await page.getByRole('menuitem', { name: 'Edit configuration' }).click()
await expect(page).toHaveURL(/\/app\/model-editor\//)
const back = page.getByRole('button', { name: /Back to Manage/ })
const back = page.getByRole('button', { name: /Back to System/ })
await expect(back).toBeVisible({ timeout: 10_000 })
await back.click()
@@ -89,6 +89,6 @@ test.describe('Model Editor — Back navigation', () => {
test('falls back to "Back to Manage" on a direct visit with no origin state', async ({ page }) => {
await page.goto('/app/model-editor/mock-model')
await expect(page.getByRole('button', { name: /Back to Manage/ })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /Back to System/ })).toBeVisible({ timeout: 10_000 })
})
})

View File

@@ -86,7 +86,8 @@
"type": "Type",
"value": "Value",
"search": "Search...",
"selectPlaceholder": "Select an option..."
"selectPlaceholder": "Select an option...",
"noMatch": "No matches"
},
"time": {
"now": "now",

View File

@@ -0,0 +1,38 @@
{
"title": {
"add": "Add Model",
"edit": "Model Editor"
},
"subtitle": {
"chooseModelType": "Choose a model type to get started",
"newModel": "New model"
},
"actions": {
"backTo": "Back to {{page}}",
"system": "System",
"templates": "Templates",
"createModel": "Create Model",
"saveChanges": "Save Changes",
"saving": "Saving...",
"saved": "Saved",
"switchWarning": "Save or discard changes before switching tabs.",
"discardAndSwitch": "Discard & Switch"
},
"tabs": {
"interactive": "Interactive",
"yaml": "YAML",
"yamlDescription": "Edit the YAML directly. The model name must be set in the YAML for create to work."
},
"forms": {
"modelName": {
"label": "Model Name",
"placeholder": "my-model-name",
"hint": "Use letters, numbers, hyphens, underscores, and dots only."
},
"empty": {
"nav": "Use the search bar above to add fields",
"title": "No fields configured",
"text": "Use the search bar above to find and add configuration fields."
}
}
}

View File

@@ -1,6 +1,7 @@
{
"title": "Install Models",
"subtitle": "Browse and install AI models from the gallery",
"models": "Models",
"stats": {
"available": "Available",
"installed": "Installed"
@@ -89,5 +90,11 @@
"loadFailed": "Failed to load models: {{message}}",
"installFailed": "Failed to install: {{message}}",
"deleteFailed": "Failed to delete: {{message}}"
},
"selector": {
"loading": "Loading models...",
"selectModel": "Select model...",
"searchPlaceholder": "Search models...",
"noModels": "No models available"
}
}

View File

@@ -86,7 +86,8 @@
"type": "Tipe",
"value": "Nilai",
"search": "Cari...",
"selectPlaceholder": "Pilih opsi..."
"selectPlaceholder": "Pilih opsi...",
"noMatch": "Tidak ada yang cocok"
},
"time": {
"now": "baru saja",
@@ -106,4 +107,4 @@
"gigabytes": "GB",
"terabytes": "TB"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"title": "Impor Model Baru",
"subtitle": {
"simple": "Import model dari URI — deteksi otomatis memilih backend.",
"simple": "Impor model dari URI — deteksi otomatis memilih backend.",
"powerYaml": "Tulis konfigurasi YAML lengkap untuk model.",
"powerPrefs": "Preferensi impor tingkat lanjut."
},
@@ -139,4 +139,4 @@
"local": "File konfigurasi YAML lokal"
}
}
}
}

View File

@@ -0,0 +1,38 @@
{
"title": {
"add": "Tambah Model",
"edit": "Editor Model"
},
"subtitle": {
"chooseModelType": "Pilih tipe model untuk memulai",
"newModel": "Model baru"
},
"actions": {
"backTo": "Kembali ke {{page}}",
"system": "Sistem",
"templates": "Templat",
"createModel": "Buat Model",
"saveChanges": "Simpan Perubahan",
"saving": "Menyimpan...",
"saved": "Tersimpan",
"switchWarning": "Simpan atau buang perubahan sebelum beralih tab.",
"discardAndSwitch": "Buang & Beralih"
},
"tabs": {
"interactive": "Interaktif",
"yaml": "YAML",
"yamlDescription": "Edit YAML secara langsung. Nama model harus diatur di YAML agar pembuatan berhasil."
},
"forms": {
"modelName": {
"label": "Nama Model",
"placeholder": "nama-model-saya",
"hint": "Gunakan huruf, angka, tanda hubung, garis bawah, dan titik saja."
},
"empty": {
"nav": "Gunakan kolom pencarian di atas untuk menambahkan field",
"title": "Tidak ada field yang dikonfigurasi",
"text": "Gunakan kolom pencarian di atas untuk menemukan dan menambahkan field konfigurasi."
}
}
}

View File

@@ -1,6 +1,7 @@
{
"title": "Instal Model",
"subtitle": "Telusuri dan instal model AI dari galeri",
"models": "Model",
"stats": {
"available": "Tersedia",
"installed": "Terinstal"
@@ -89,5 +90,11 @@
"loadFailed": "Gagal memuat model: {{message}}",
"installFailed": "Gagal menginstal: {{message}}",
"deleteFailed": "Gagal menghapus: {{message}}"
},
"selector": {
"loading": "Memuat model...",
"selectModel": "Pilih model...",
"searchPlaceholder": "Cari model...",
"noModels": "Model tidak tersedia"
}
}
}

View File

@@ -1,12 +1,14 @@
import { useEffect, useMemo } from 'react'
import { useModels } from '../hooks/useModels'
import SearchableSelect from './SearchableSelect'
import { useTranslation } from 'react-i18next'
export default function ModelSelector({
value, onChange, capability, className = '',
options: externalOptions, loading: externalLoading,
disabled: externalDisabled, searchPlaceholder, style,
}) {
const { t } = useTranslation('models')
// Skip capability fetch when external options are provided (capability will be undefined)
const { models: hookModels, loading: hookLoading } = useModels(externalOptions ? undefined : capability)
@@ -28,8 +30,8 @@ export default function ModelSelector({
value={value || ''}
onChange={onChange}
options={modelNames}
placeholder={isLoading ? 'Loading models...' : (modelNames.length === 0 ? 'No models available' : 'Select model...')}
searchPlaceholder={searchPlaceholder || 'Search models...'}
placeholder={isLoading ? t('selector.loading') : (modelNames.length === 0 ? t('selector.noModels') : t('selector.selectModel'))}
searchPlaceholder={searchPlaceholder || t('selector.searchPlaceholder')}
disabled={isDisabled}
className={className}
style={style}

View File

@@ -1,10 +1,12 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
export default function SearchableSelect({
value, onChange, options, placeholder = 'Select...',
allOption, searchPlaceholder = 'Search...',
disabled = false, style, className = '',
}) {
const { t } = useTranslation('common')
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [focusIndex, setFocusIndex] = useState(-1)
@@ -226,7 +228,7 @@ export default function SearchableSelect({
})}
{filtered.length === 0 && !allOption && (
<div style={{ padding: '6px 10px', fontSize: '0.8125rem', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No matches
{t('forms.noMatch')}
</div>
)}
</div>

View File

@@ -675,7 +675,7 @@ export default function Manage() {
onClick: () => handleTogglePinned(model.id, model.pinned),
disabled: pinningModels.has(model.id) || !!model.disabled },
{ key: 'edit', icon: 'fa-pen-to-square', label: 'Edit configuration',
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, 'Manage') }) },
onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, { state: fromState(location, t('manage.title')) }) },
{ key: 'logs', icon: 'fa-terminal', label: 'Backend logs',
onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) },
{ divider: true },

View File

@@ -12,6 +12,7 @@ import ConfigFieldRenderer from '../components/ConfigFieldRenderer'
import { FormContextProvider } from '../contexts/FormContext'
import TemplateSelector from '../components/TemplateSelector'
import MODEL_TEMPLATES from '../utils/modelTemplates'
import { useTranslation } from 'react-i18next'
const SECTION_ICONS = {
general: 'fa-cog', llm: 'fa-microchip', parameters: 'fa-sliders',
@@ -70,6 +71,7 @@ function defaultForType(uiType) {
}
export default function ModelEditor() {
const { t } = useTranslation('modelEditor')
const { name } = useParams()
const [searchParams] = useSearchParams()
const navigate = useNavigate()
@@ -397,6 +399,10 @@ export default function ModelEditor() {
if (loading) return <div className="page page--medium" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (metaError) return <div className="page page--medium"><div className="empty-state"><p className="empty-state-text">Failed to load config metadata: {metaError}</p></div></div>
const backPage = isCreateMode && selectedTemplate ? t('actions.templates')
: backState ? backState.fromLabel
: isCreateMode ? t('actions.models') : t('actions.system')
return (
<FormContextProvider formData={values}>
<div className="page page--medium" style={{ padding: 0 }}>
@@ -406,10 +412,10 @@ export default function ModelEditor() {
padding: 'var(--spacing-lg) var(--spacing-lg) var(--spacing-md)',
}}>
<div>
<h1 className="page-title">{isCreateMode ? 'Add Model' : 'Model Editor'}</h1>
<h1 className="page-title">{isCreateMode ? t('title.add') : t('title.edit')}</h1>
<p className="page-subtitle">
{isCreateMode
? (showTemplateSelector ? 'Choose a model type to get started' : `New model${selectedTemplate ? `${selectedTemplate.label}` : ''}`)
? (showTemplateSelector ? t('subtitle.chooseModelType') : `${t('subtitle.newModel')}${selectedTemplate ? `${selectedTemplate.label}` : ''}`)
: decodeURIComponent(name)}
</p>
</div>
@@ -419,20 +425,16 @@ export default function ModelEditor() {
else if (backState) navigate(backState.from)
else navigate(isCreateMode ? '/app/models' : '/app/manage')
}}>
<i className="fas fa-arrow-left" /> Back to {
isCreateMode && selectedTemplate ? 'Templates'
: backState ? backState.fromLabel
: isCreateMode ? 'Models' : 'Manage'
}
<i className="fas fa-arrow-left" /> {t('actions.backTo', {page: backPage})}
</button>
{!showTemplateSelector && tab === 'interactive' && (
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleInteractiveSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isCreateMode ? 'Create Model' : (isDirty ? 'Save Changes' : 'Saved')}</>}
{saving ? <><LoadingSpinner size="sm" /> {t('actions.saving')}</> : <><i className="fas fa-save" /> {isCreateMode ? t('actions.createModel') : (isDirty ? t('actions.saveChanges') : t('actions.saved'))}</>}
</button>
)}
{!showTemplateSelector && tab === 'yaml' && (
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleYamlSave} disabled={saving || !isDirty}>
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isCreateMode ? 'Create Model' : (isDirty ? 'Save Changes' : 'Saved')}</>}
{saving ? <><LoadingSpinner size="sm" /> {t('actions.saving')}</> : <><i className="fas fa-save" /> {isCreateMode ? t('actions.createModel') : (isDirty ? t('actions.saveChanges') : t('actions.saved'))}</>}
</button>
)}
</div>
@@ -448,17 +450,17 @@ export default function ModelEditor() {
display: 'flex', gap: 0, padding: '0 var(--spacing-lg)',
borderBottom: '1px solid var(--color-border)',
}}>
{['interactive', 'yaml'].map(t => {
const active = tab === t
{['interactive', 'yaml'].map(tb => {
const active = tab === tb
const blocked = !active && isDirty
return (
<button
key={t}
key={tb}
onClick={() => {
if (active) return
if (blocked) { setTabSwitchWarning(true); return }
setTabSwitchWarning(false)
setTab(t)
setTab(tb)
}}
style={{
padding: 'var(--spacing-sm) var(--spacing-md)', border: 'none',
@@ -471,8 +473,8 @@ export default function ModelEditor() {
transition: 'all 150ms',
}}
>
<i className={`fas ${t === 'interactive' ? 'fa-sliders' : 'fa-code'}`} style={{ marginRight: 6 }} />
{t === 'interactive' ? 'Interactive' : 'YAML'}
<i className={`fas ${tb === 'interactive' ? 'fa-sliders' : 'fa-code'}`} style={{ marginRight: 6 }} />
{tb === 'interactive' ? t('tabs.interactive') : t('tabs.yaml')}
</button>
)
})}
@@ -485,7 +487,7 @@ export default function ModelEditor() {
background: 'var(--color-warning-light, rgba(245, 158, 11, 0.08))',
}}>
<i className="fas fa-exclamation-triangle" />
<span>Save or discard changes before switching tabs.</span>
<span>{t('actions.switchWarning')}</span>
<button
className="btn btn-secondary"
style={{ marginLeft: 'auto', padding: '2px 10px', fontSize: '0.75rem' }}
@@ -500,7 +502,7 @@ export default function ModelEditor() {
setTab(tab === 'yaml' ? 'interactive' : 'yaml')
}}
>
Discard &amp; Switch
{t('actions.discardAndSwitch')}
</button>
</div>
)}
@@ -512,7 +514,7 @@ export default function ModelEditor() {
<div style={{ padding: '0 var(--spacing-lg) var(--spacing-lg)' }}>
{isCreateMode && (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-sm)' }}>
Edit the YAML directly. The model name must be set in the YAML for create to work.
{t('tabs.yamlDescription')}
</p>
)}
<CodeEditor
@@ -533,18 +535,18 @@ export default function ModelEditor() {
<div className="card" style={{ padding: 'var(--spacing-md)' }}>
<label className="form-label" style={{ fontWeight: 600 }}>
<i className="fas fa-tag" style={{ marginRight: '6px', color: 'var(--color-primary)' }} />
Model Name
{t('forms.modelName.label')}
</label>
<input
className="input"
type="text"
value={values['name'] || ''}
onChange={e => handleFieldChange('name', e.target.value)}
placeholder="my-model-name"
placeholder={t('forms.modelName.placeholder')}
style={{ maxWidth: 400 }}
/>
<p style={{ marginTop: 'var(--spacing-xs)', fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
Use letters, numbers, hyphens, underscores, and dots only.
{t('forms.modelName.hint')}
</p>
</div>
</div>
@@ -596,7 +598,7 @@ export default function ModelEditor() {
))}
{activeSections.length === 0 && (
<div style={{ padding: '12px', fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
Use the search bar above to add fields
{t('forms.empty.nav')}
</div>
)}
</nav>
@@ -610,9 +612,9 @@ export default function ModelEditor() {
{activeSections.length === 0 && (
<div className="card" style={{ padding: 'var(--spacing-xl)', textAlign: 'center' }}>
<i className="fas fa-sliders" style={{ fontSize: '2rem', color: 'var(--color-text-muted)', marginBottom: 'var(--spacing-md)' }} />
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>No fields configured</h3>
<h3 style={{ marginBottom: 'var(--spacing-sm)' }}>{t('forms.empty.title')}</h3>
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Use the search bar above to find and add configuration fields.
{t('forms.empty.text')}
</p>
</div>
)}

View File

@@ -288,7 +288,7 @@ export default function Models() {
</a>
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, 'Models') })}>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/app/model-editor', { state: fromState(location, t('models')) })}>
<i className="fas fa-plus" /> {t('actions.addModel')}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/import-model')}>