Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions server/skillhub-app/src/main/resources/messages_ru.properties

Large diffs are not rendered by default.

16 changes: 1 addition & 15 deletions web/src/features/notification/notification-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,12 @@ import { resolveNotificationDisplay } from './notification-content'
import { useAuth } from '@/features/auth/use-auth'
import { useNotifications, useMarkAllRead, useMarkRead } from './use-notifications'
import { resolveNotificationTarget } from './notification-target'
import { formatRelativeTime } from '@/shared/lib/format-relative-time'

interface Props {
onClose: () => void
}

function formatRelativeTime(dateStr: string, lang: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60_000)
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)

const isChinese = lang.startsWith('zh')

if (minutes < 1) return isChinese ? '刚刚' : 'just now'
if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m`
if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h`
if (days < 30) return isChinese ? `${days}天` : `${days}d`
return new Date(dateStr).toLocaleDateString()
}

/**
* Dropdown panel showing the latest 5 notifications with mark-all-read and view-all actions.
*/
Expand Down
8 changes: 7 additions & 1 deletion web/src/i18n/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ vi.mock('./locales/zh.json', () => ({
default: { greeting: '你好' },
}))

vi.mock('./locales/ru.json', () => ({
default: { greeting: 'Привет' },
}))

// Import triggers the side-effect initialization
await import('./config')

Expand All @@ -54,11 +58,13 @@ describe('i18n config', () => {
expect(initOptions.detection.caches).toEqual(['localStorage'])
})

it('registers both english and chinese resource bundles', () => {
it('registers english, russian and chinese resource bundles', () => {
const initOptions = initMock.mock.calls[0][0]
expect(initOptions.resources).toHaveProperty('en')
expect(initOptions.resources).toHaveProperty('ru')
expect(initOptions.resources).toHaveProperty('zh')
expect(initOptions.resources.en).toHaveProperty('translation')
expect(initOptions.resources.ru).toHaveProperty('translation')
expect(initOptions.resources.zh).toHaveProperty('translation')
})
})
2 changes: 2 additions & 0 deletions web/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import en from './locales/en.json'
import ru from './locales/ru.json'
import zh from './locales/zh.json'

/**
Expand All @@ -15,6 +16,7 @@ i18n
.init({
resources: {
en: { translation: en },
ru: { translation: ru },
zh: { translation: zh },
},
fallbackLng: 'en',
Expand Down
5 changes: 4 additions & 1 deletion web/src/i18n/landing-quick-start-locale.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import ru from './locales/ru.json'
import zh from './locales/zh.json'

describe('landing quick start locales', () => {
it('uses localized agent setup prompts for chinese and english', () => {
it('uses localized agent setup prompts for chinese, english, and russian', () => {
expect(zh.landing.quickStart.agent.command).toBe('阅读 https://www.example.com/registry/skill.md,并按照说明完成 SkillHub Skills Registry 的配置')
expect(en.landing.quickStart.agent.command).toBe('Read https://www.example.com/registry/skill.md and follow the instructions to setup SkillHub Skills Registry')
expect(ru.landing.quickStart.agent.command).toBe('Прочитайте https://www.example.com/registry/skill.md и следуйте инструкциям для настройки SkillHub Skills Registry')
})

it('provides command templates with url placeholder for dynamic rendering', () => {
expect(zh.landing.quickStart.agent.commandTemplate).toBe('阅读 {{url}},并按照说明完成 SkillHub Skills Registry 的配置')
expect(en.landing.quickStart.agent.commandTemplate).toBe('Read {{url}} and follow the instructions to setup SkillHub Skills Registry')
expect(ru.landing.quickStart.agent.commandTemplate).toBe('Прочитайте {{url}} и следуйте инструкциям для настройки SkillHub Skills Registry')
})

it('exposes CLI install command in both locales', () => {
Expand Down
1,567 changes: 1,567 additions & 0 deletions web/src/i18n/locales/ru.json

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions web/src/i18n/ru-locale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import ru from './locales/ru.json'

function leafKeys(value: unknown, prefix = ''): string[] {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
leafKeys(child, prefix ? `${prefix}.${key}` : key),
)
}
return [prefix]
}

function placeholders(text: string): string[] {
return [...text.matchAll(/\{\{[^}]+\}\}/g)].map((match) => match[0]).sort()
}

describe('russian locale', () => {
it('mirrors the english key tree', () => {
expect(leafKeys(ru).sort()).toEqual(leafKeys(en).sort())
})

it('preserves interpolation placeholders', () => {
const enMap = Object.fromEntries(leafKeys(en).map((key) => {
const parts = key.split('.')
let cursor: unknown = en
for (const part of parts) {
cursor = (cursor as Record<string, unknown>)[part]
}
return [key, String(cursor)]
}))
const mismatches: string[] = []
for (const key of leafKeys(ru)) {
const parts = key.split('.')
let cursor: unknown = ru
for (const part of parts) {
cursor = (cursor as Record<string, unknown>)[part]
}
if (placeholders(String(cursor)).join() !== placeholders(enMap[key] ?? '').join()) {
mismatches.push(key)
}
}
expect(mismatches).toEqual([])
})

it('translates core navigation labels', () => {
expect(ru.nav.home).not.toBe(en.nav.home)
expect(ru.nav.home.length).toBeGreaterThan(0)
expect(ru.login.title.length).toBeGreaterThan(0)
})
})
14 changes: 1 addition & 13 deletions web/src/pages/notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { DashboardPageHeader } from '@/shared/components/dashboard-page-header'
import { Pagination } from '@/shared/components/pagination'
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { formatRelativeTime } from '@/shared/lib/format-relative-time'

const PAGE_SIZE = 20

Expand All @@ -29,19 +30,6 @@ function getCategoryKey(cat: Category): string {
}
}

function formatRelativeTime(dateStr: string, lang: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60_000)
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)
const isChinese = lang.startsWith('zh')
if (minutes < 1) return isChinese ? '刚刚' : 'just now'
if (minutes < 60) return isChinese ? `${minutes}分钟` : `${minutes}m`
if (hours < 24) return isChinese ? `${hours}小时` : `${hours}h`
if (days < 30) return isChinese ? `${days}天` : `${days}d`
return new Date(dateStr).toLocaleDateString()
}

function CategoryBadge({ category }: { category: NotificationItem['category'] }) {
const { t } = useTranslation()
const colorMap: Record<NotificationItem['category'], string> = {
Expand Down
3 changes: 2 additions & 1 deletion web/src/shared/components/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
const languages = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: 'English' },
{ code: 'ru', name: 'Русский' },
]

// 获取当前语言的主要代码(去掉地区代码)
// Primary language code only (strip region, e.g. ru-RU → ru).
const currentLangCode = i18n.language?.split('-')[0] || 'zh'
const currentLanguage = languages.find((lang) => lang.code === currentLangCode) || languages[0]

Expand Down
3 changes: 3 additions & 0 deletions web/src/shared/lib/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ function isAccountDisabledError(error: ApiError): boolean {
const accountDisabledMessages = [
i18n.t('apiError.auth.accountDisabled'),
i18n.getFixedT('en')('apiError.auth.accountDisabled'),
i18n.getFixedT('ru')('apiError.auth.accountDisabled'),
i18n.getFixedT('zh')('apiError.auth.accountDisabled'),
]
const normalizedServerMessage = (error.serverMessage ?? '').toLowerCase()
Expand All @@ -43,6 +44,8 @@ function isAccountDisabledError(error: ApiError): boolean {
|| normalizedMessage.includes('disabled')
|| (error.serverMessage ?? '').includes('禁用')
|| error.message.includes('禁用')
|| (error.serverMessage ?? '').toLowerCase().includes('отключ')
|| error.message.toLowerCase().includes('отключ')
}

export function handleApiError(error: unknown): void {
Expand Down
34 changes: 34 additions & 0 deletions web/src/shared/lib/format-relative-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Formats a timestamp as a compact relative time string for notification UI.
* Mirrors the zh inline pattern; ru support added for the Russian locale.
*/
export function formatRelativeTime(dateStr: string, lang: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60_000)
const hours = Math.floor(diff / 3_600_000)
const days = Math.floor(diff / 86_400_000)
const isChinese = lang.startsWith('zh')
const isRussian = lang.startsWith('ru')

if (minutes < 1) {
if (isChinese) return '刚刚'
if (isRussian) return 'только что'
return 'just now'
}
if (minutes < 60) {
if (isChinese) return `${minutes}分钟`
if (isRussian) return `${minutes} мин`
return `${minutes}m`
}
if (hours < 24) {
if (isChinese) return `${hours}小时`
if (isRussian) return `${hours} ч`
return `${hours}h`
}
if (days < 30) {
if (isChinese) return `${days}天`
if (isRussian) return `${days} д`
return `${days}d`
}
return new Date(dateStr).toLocaleDateString(isRussian ? 'ru-RU' : undefined)
}
Loading