mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba8681eb7 |
No files matched your search
@@ -9,6 +9,7 @@ import { ZoneWidgets } from './Widgets'
|
||||
import { Toc } from './Toc'
|
||||
import { Footer } from './Footer'
|
||||
import { ChatLayout, AgentUILayout, CopilotWidget, PlaygroundLayout } from './layouts'
|
||||
import { LocaleProvider } from './i18n/LocaleProvider'
|
||||
|
||||
function SkipLink({ enabled }: { enabled?: boolean }) {
|
||||
if (!enabled) return null
|
||||
@@ -372,5 +373,9 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
return renderByStyle()
|
||||
return (
|
||||
<LocaleProvider config={config.i18n}>
|
||||
{renderByStyle()}
|
||||
</LocaleProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// Footer component
|
||||
import type { UIConfig } from './types'
|
||||
import { useLocale } from './i18n/LocaleProvider'
|
||||
|
||||
export function Footer({ config }: { config: UIConfig }) {
|
||||
const { t } = useLocale()
|
||||
// Resolve footer component via template slot ref (e.g., footer_main)
|
||||
const footerSlot = config.templates?.docs?.slots?.footer
|
||||
const footerRef = footerSlot?.ref
|
||||
@@ -20,7 +22,7 @@ export function Footer({ config }: { config: UIConfig }) {
|
||||
<div className="w-5 h-5 rounded bg-gradient-to-br from-primary/60 to-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground text-[8px] font-bold">AI</span>
|
||||
</div>
|
||||
<span>{footer?.text || `© ${new Date().getFullYear()} PraisonAIUI`}</span>
|
||||
<span>{footer?.text || t('footer.copyright', `© ${new Date().getFullYear()} PraisonAIUI`)}</span>
|
||||
</div>
|
||||
<nav className="flex items-center gap-6">
|
||||
{footer?.links?.map((link) => (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Header component
|
||||
import type { UIConfig } from './types'
|
||||
import { useLocale } from './i18n/LocaleProvider'
|
||||
|
||||
export function Header({ config }: { config: UIConfig }) {
|
||||
const { locale, locales, setLocale, t } = useLocale()
|
||||
// Resolve header component via template slot ref (e.g., header_main)
|
||||
const headerSlot = config.templates?.docs?.slots?.header
|
||||
const headerRef = headerSlot?.ref
|
||||
@@ -56,6 +58,21 @@ export function Header({ config }: { config: UIConfig }) {
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{locales.length > 1 && (
|
||||
<select
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e.target.value)}
|
||||
className="h-9 px-3 text-sm bg-background border border-input rounded-md focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
aria-label={t('locale.switcher')}
|
||||
data-testid="locale-select"
|
||||
>
|
||||
{locales.map((loc) => (
|
||||
<option key={loc} value={loc}>
|
||||
{loc.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{header?.cta && (
|
||||
<a
|
||||
href={header.cta.href}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef, type KeyboardEvent, type ChangeEvent } from 'react'
|
||||
import { useLocale } from '../i18n/LocaleProvider'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, files?: File[]) => void
|
||||
@@ -12,9 +13,11 @@ export function ChatInput({
|
||||
onSend,
|
||||
onCancel,
|
||||
isStreaming = false,
|
||||
placeholder = 'Type a message...',
|
||||
placeholder,
|
||||
enableFileUpload = false,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useLocale()
|
||||
const finalPlaceholder = placeholder || t('chat.placeholder', 'Type a message...')
|
||||
const [input, setInput] = useState('')
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -119,7 +122,7 @@ export function ChatInput({
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={placeholder}
|
||||
placeholder={finalPlaceholder}
|
||||
disabled={isStreaming}
|
||||
rows={1}
|
||||
className="flex-1 resize-none bg-transparent px-3 py-2.5 text-sm focus:outline-none disabled:opacity-50 placeholder:text-muted-foreground/60"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import type { I18nConfig } from '../types'
|
||||
|
||||
interface TranslationMap {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
interface LocaleContextType {
|
||||
locale: string
|
||||
locales: string[]
|
||||
rtlLocales: string[]
|
||||
setLocale: (locale: string) => void
|
||||
t: (key: string, fallback?: string) => string
|
||||
isRTL: boolean
|
||||
}
|
||||
|
||||
const LocaleContext = createContext<LocaleContextType | null>(null)
|
||||
|
||||
const DEFAULT_TRANSLATIONS: Record<string, TranslationMap> = {
|
||||
en: {
|
||||
'nav.home': 'Home',
|
||||
'nav.docs': 'Documentation',
|
||||
'nav.api': 'API',
|
||||
'nav.settings': 'Settings',
|
||||
'chat.placeholder': 'Type your message...',
|
||||
'chat.send': 'Send',
|
||||
'footer.copyright': 'All rights reserved',
|
||||
'footer.privacy': 'Privacy',
|
||||
'footer.terms': 'Terms',
|
||||
'locale.switcher': 'Language',
|
||||
},
|
||||
es: {
|
||||
'nav.home': 'Inicio',
|
||||
'nav.docs': 'Documentación',
|
||||
'nav.api': 'API',
|
||||
'nav.settings': 'Configuración',
|
||||
'chat.placeholder': 'Escribe tu mensaje...',
|
||||
'chat.send': 'Enviar',
|
||||
'footer.copyright': 'Todos los derechos reservados',
|
||||
'footer.privacy': 'Privacidad',
|
||||
'footer.terms': 'Términos',
|
||||
'locale.switcher': 'Idioma',
|
||||
},
|
||||
ar: {
|
||||
'nav.home': 'الصفحة الرئيسية',
|
||||
'nav.docs': 'الوثائق',
|
||||
'nav.api': 'واجهة البرمجة',
|
||||
'nav.settings': 'الإعدادات',
|
||||
'chat.placeholder': 'اكتب رسالتك...',
|
||||
'chat.send': 'إرسال',
|
||||
'footer.copyright': 'جميع الحقوق محفوظة',
|
||||
'footer.privacy': 'الخصوصية',
|
||||
'footer.terms': 'الشروط',
|
||||
'locale.switcher': 'اللغة',
|
||||
},
|
||||
}
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
config
|
||||
}: {
|
||||
children: ReactNode
|
||||
config?: I18nConfig
|
||||
}) {
|
||||
const defaultLocale = config?.defaultLocale || 'en'
|
||||
const availableLocales = config?.locales || ['en']
|
||||
const rtlLocales = config?.rtlLocales || []
|
||||
const fallbackLocale = config?.fallbackLocale || 'en'
|
||||
|
||||
const [locale, setLocaleState] = useState(() => {
|
||||
const saved = localStorage.getItem('locale')
|
||||
return saved && availableLocales.includes(saved) ? saved : defaultLocale
|
||||
})
|
||||
|
||||
const [translations, setTranslations] = useState<Record<string, TranslationMap>>({})
|
||||
|
||||
useEffect(() => {
|
||||
const loadTranslations = async () => {
|
||||
const translationCache: Record<string, TranslationMap> = {}
|
||||
|
||||
for (const loc of availableLocales) {
|
||||
try {
|
||||
const response = await fetch(`/api/i18n/strings/${loc}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
translationCache[loc] = data.strings || data || DEFAULT_TRANSLATIONS[loc] || {}
|
||||
} else {
|
||||
translationCache[loc] = DEFAULT_TRANSLATIONS[loc] || {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load translations for ${loc}`, error)
|
||||
translationCache[loc] = DEFAULT_TRANSLATIONS[loc] || {}
|
||||
}
|
||||
}
|
||||
|
||||
setTranslations(translationCache)
|
||||
}
|
||||
|
||||
if (config?.locales && config.locales.length > 0) {
|
||||
loadTranslations()
|
||||
} else {
|
||||
setTranslations(DEFAULT_TRANSLATIONS)
|
||||
}
|
||||
}, [config, availableLocales])
|
||||
|
||||
useEffect(() => {
|
||||
const isRTL = rtlLocales.includes(locale)
|
||||
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
|
||||
document.documentElement.lang = locale
|
||||
localStorage.setItem('locale', locale)
|
||||
}, [locale, rtlLocales])
|
||||
|
||||
const setLocale = (newLocale: string) => {
|
||||
if (availableLocales.includes(newLocale)) {
|
||||
setLocaleState(newLocale)
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, fallback?: string): string => {
|
||||
const localeTranslations = translations[locale] || {}
|
||||
const fallbackTranslations = translations[fallbackLocale] || {}
|
||||
|
||||
return localeTranslations[key] ||
|
||||
fallbackTranslations[key] ||
|
||||
fallback ||
|
||||
key
|
||||
}
|
||||
|
||||
const isRTL = rtlLocales.includes(locale)
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{
|
||||
locale,
|
||||
locales: availableLocales,
|
||||
rtlLocales,
|
||||
setLocale,
|
||||
t,
|
||||
isRTL
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const context = useContext(LocaleContext)
|
||||
if (!context) {
|
||||
return {
|
||||
locale: 'en',
|
||||
locales: ['en'],
|
||||
rtlLocales: [],
|
||||
setLocale: () => {},
|
||||
t: (key: string, fallback?: string) => fallback || key,
|
||||
isRTL: false
|
||||
}
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -333,7 +333,7 @@ class Compiler:
|
||||
"ariaLabels": self.config.a11y.aria_labels,
|
||||
}
|
||||
|
||||
# I18n configuration - emit for frontend consumption (experimental)
|
||||
# I18n configuration - emit for frontend consumption
|
||||
if self.config.i18n:
|
||||
result["i18n"] = {
|
||||
"defaultLocale": self.config.i18n.default_locale,
|
||||
|
||||
@@ -59,8 +59,8 @@ class ConfigFeatureRegistry:
|
||||
"SEO configuration with title templates, OG tags, Twitter meta - basic implementation"),
|
||||
"a11y": FeatureInfo("a11y", FeatureStatus.IMPLEMENTED, "0.5.0",
|
||||
"Accessibility configuration - basic implementation for skip links and ARIA labels"),
|
||||
"i18n": FeatureInfo("i18n", FeatureStatus.EXPERIMENTAL, None,
|
||||
"Internationalization accepted in schema but content pipeline/routing not implemented"),
|
||||
"i18n": FeatureInfo("i18n", FeatureStatus.IMPLEMENTED, "0.5.0",
|
||||
"Internationalization with locale switching and RTL support"),
|
||||
}
|
||||
|
||||
def get_feature(self, name: str) -> FeatureInfo | None:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Tests for i18n and accessibility configuration."""
|
||||
|
||||
|
||||
from praisonaiui.schema.features import get_feature_registry
|
||||
from praisonaiui.schema.models import (
|
||||
A11yConfig,
|
||||
Config,
|
||||
I18nConfig,
|
||||
SiteConfig,
|
||||
)
|
||||
from praisonaiui.schema.validators import validate_config
|
||||
|
||||
|
||||
class TestI18nConfig:
|
||||
@@ -112,3 +114,32 @@ class TestConfigWithI18nA11y:
|
||||
|
||||
assert config.i18n.locales == ["en", "ja"]
|
||||
assert config.a11y.reduce_motion is True
|
||||
|
||||
|
||||
class TestI18nImplementation:
|
||||
"""Test i18n feature implementation status."""
|
||||
|
||||
def test_i18n_is_implemented(self):
|
||||
"""Test that i18n is marked as IMPLEMENTED in feature registry."""
|
||||
registry = get_feature_registry()
|
||||
assert registry.is_implemented("i18n") is True
|
||||
assert registry.is_experimental("i18n") is False
|
||||
|
||||
def test_strict_validate_i18n_no_error(self):
|
||||
"""Test that strict validation passes with valid i18n config."""
|
||||
config = Config(
|
||||
site=SiteConfig(title="I18n Test"),
|
||||
i18n=I18nConfig(
|
||||
defaultLocale="en",
|
||||
locales=["en", "es", "ar"],
|
||||
rtlLocales=["ar"],
|
||||
),
|
||||
)
|
||||
|
||||
result = validate_config(config, strict=True)
|
||||
|
||||
assert result.valid is True
|
||||
# Verify no 4001 errors for i18n
|
||||
experimental_errors = [e for e in result.errors if e.code == 4001]
|
||||
i18n_errors = [e for e in experimental_errors if "i18n" in e.message]
|
||||
assert len(i18n_errors) == 0
|
||||
Reference in new issue
Block a user