mirror of
https://github.com/iflytek/skillhub.git
synced 2026-11-14 09:01:16 +00:00
feat(i18n): add Korean language support to the web client
Signed-off-by: JeonJunYeong <wnsdud9476@gmail.com>
This commit is contained in:
parent
5f5fcf9bb9
commit
d1ea293b1f
38 changed files with 4300 additions and 153 deletions
|
|
@ -107,7 +107,7 @@ with the Skill's source and the problem it solves, or submit a PR by following t
|
|||
expand.
|
||||
- **Pluggable Storage** — Local filesystem for development, S3 /
|
||||
MinIO for production. Swap via config.
|
||||
- **Internationalization** — Multi-language support with i18next.
|
||||
- **Internationalization** — Multi-language support with i18next: English, Simplified Chinese, Russian, and Korean.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ Skill,欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用
|
|||
- **API 令牌管理** — 为 CLI 和程序化访问生成作用域令牌,采用基于前缀的安全哈希。
|
||||
- **CLI 优先** — 原生 REST API,加上对现有 ClawHub 风格注册中心客户端的兼容层。原生 CLI API 是主要支持路径,协议兼容性持续扩展中。
|
||||
- **可插拔存储** — 开发环境使用本地文件系统,生产环境使用 S3 / MinIO。通过配置切换。
|
||||
- **国际化** — 使用 i18next 支持多语言。
|
||||
- **国际化** — 使用 i18next 支持英语、简体中文、俄语和韩语。
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
|
|
|||
|
|
@ -265,9 +265,9 @@ export function Layout() {
|
|||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold text-foreground">{t('footer.project')}</h4>
|
||||
<ul className="space-y-2.5 text-sm text-muted-foreground">
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/LICENSE" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>License</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/CONTRIBUTING.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Contributing</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/releases" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>Changelog</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/LICENSE" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.license')}</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/CONTRIBUTING.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.contributing')}</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/releases" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.changelog')}</a></li>
|
||||
<li><a href="https://github.com/iflytek/skillhub/blob/main/CODE_OF_CONDUCT.md" target="_blank" rel="noreferrer" className={FOOTER_LINK_CLASS_NAME}>{t('footer.codeOfConduct')}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { lazy, Suspense, type ComponentType } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createRouter, createRoute, createRootRoute, redirect } from '@tanstack/react-router'
|
||||
import { Layout } from './layout'
|
||||
import { getCurrentUser, type GithubSyncRepositoryQuery, type GithubSyncRunQuery } from '@/api/client'
|
||||
|
|
@ -23,9 +24,10 @@ captureInvitationFragment()
|
|||
export { ORIGINAL_URL_SEARCH }
|
||||
|
||||
function RouteLoadingFallback() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,46 @@ describe('resolveNotificationDisplay', () => {
|
|||
expect(display.description).toContain('1.0.0')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['REVIEW_SUBMITTED', '스킬 검토 제출'],
|
||||
['REVIEW_APPROVED', '스킬 검토 승인'],
|
||||
['REVIEW_REJECTED', '스킬 검토 반려'],
|
||||
['PROMOTION_SUBMITTED', '스킬 승격 제출'],
|
||||
['PROMOTION_APPROVED', '스킬 승격 승인'],
|
||||
['PROMOTION_REJECTED', '스킬 승격 반려'],
|
||||
['REPORT_SUBMITTED', '스킬 신고 접수'],
|
||||
['REPORT_RESOLVED', '스킬 신고 처리 완료'],
|
||||
['SKILL_PUBLISHED', '스킬 게시 완료'],
|
||||
['SUBSCRIPTION_NEW_VERSION', '구독한 스킬 업데이트'],
|
||||
['SUBSCRIPTION_VERSION_YANKED', '구독한 스킬 버전 배포 철회'],
|
||||
])('localizes %s in Korean', (eventType, title) => {
|
||||
const display = resolveNotificationDisplay({
|
||||
id: 1, category: 'REVIEW', eventType, title: 'Server title', status: 'UNREAD',
|
||||
createdAt: '2026-03-20T00:00:00Z',
|
||||
bodyJson: JSON.stringify({ skillName: 'Calendar', version: '1.0.0' }),
|
||||
}, 'ko-KR')
|
||||
expect(display.title).toBe(title)
|
||||
expect(display.description).toContain('Calendar')
|
||||
expect(display.description).toMatch(/[가-힣]/)
|
||||
if (!eventType.startsWith('REPORT_')) expect(display.description).toContain('(1.0.0)')
|
||||
})
|
||||
|
||||
it.each([undefined, 'invalid JSON', '{}', 'null'])('keeps a localized title with an absent or malformed body (%s)', (bodyJson) => {
|
||||
expect(resolveNotificationDisplay({
|
||||
id: 1, category: 'REVIEW', eventType: 'REVIEW_APPROVED', title: 'Server title',
|
||||
status: 'UNREAD', createdAt: '2026-03-20T00:00:00Z', bodyJson,
|
||||
}, 'ko')).toEqual({ title: '스킬 검토 승인', description: '' })
|
||||
})
|
||||
|
||||
it('omits version parentheses when a notification has no version', () => {
|
||||
const display = resolveNotificationDisplay({
|
||||
id: 1, category: 'REVIEW', eventType: 'REVIEW_APPROVED', title: 'Server title',
|
||||
status: 'UNREAD', createdAt: '2026-03-20T00:00:00Z',
|
||||
bodyJson: JSON.stringify({ skillName: 'Calendar' }),
|
||||
}, 'ko')
|
||||
expect(display.description).toBe('Calendar의 검토가 승인되었습니다.')
|
||||
})
|
||||
|
||||
it('falls back to the backend title when the event type is unsupported', () => {
|
||||
const display = resolveNotificationDisplay({
|
||||
category: 'PUBLISH',
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import i18n from '@/i18n/config'
|
||||
import type { NotificationItem } from '@/api/types'
|
||||
|
||||
export type NotificationDisplay = {
|
||||
|
|
@ -22,77 +23,29 @@ function parseBody(bodyJson?: string): NotificationBody {
|
|||
}
|
||||
}
|
||||
|
||||
function isChinese(language: string) {
|
||||
return language.toLowerCase().startsWith('zh')
|
||||
}
|
||||
const localizedEvents = new Set([
|
||||
'REVIEW_SUBMITTED', 'REVIEW_APPROVED', 'REVIEW_REJECTED',
|
||||
'PROMOTION_SUBMITTED', 'PROMOTION_APPROVED', 'PROMOTION_REJECTED',
|
||||
'REPORT_SUBMITTED', 'REPORT_RESOLVED', 'SKILL_PUBLISHED',
|
||||
'SUBSCRIPTION_NEW_VERSION', 'SUBSCRIPTION_VERSION_YANKED',
|
||||
])
|
||||
|
||||
export function resolveNotificationDisplay(item: NotificationItem, language: string): NotificationDisplay {
|
||||
const zh = isChinese(language)
|
||||
const zh = language.toLowerCase().startsWith('zh')
|
||||
const t = i18n.getFixedT(language)
|
||||
const body = parseBody(item.bodyJson)
|
||||
const skillName = body.skillName ?? ''
|
||||
const version = body.version ?? ''
|
||||
const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : ''
|
||||
|
||||
switch (item.eventType) {
|
||||
case 'REVIEW_SUBMITTED':
|
||||
return {
|
||||
title: zh ? '技能审核提交' : 'Review submitted',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 已提交审核。` : `${skillName}${versionSuffix} was submitted for review.`) : '',
|
||||
}
|
||||
case 'REVIEW_APPROVED':
|
||||
return {
|
||||
title: zh ? '技能审核通过' : 'Review approved',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 已审核通过。` : `${skillName}${versionSuffix} was approved.`) : '',
|
||||
}
|
||||
case 'REVIEW_REJECTED':
|
||||
return {
|
||||
title: zh ? '技能审核驳回' : 'Review rejected',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 审核未通过。` : `${skillName}${versionSuffix} was rejected.`) : '',
|
||||
}
|
||||
case 'PROMOTION_SUBMITTED':
|
||||
return {
|
||||
title: zh ? '技能推广提交' : 'Promotion submitted',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 已提交推广。` : `${skillName}${versionSuffix} was submitted for promotion.`) : '',
|
||||
}
|
||||
case 'PROMOTION_APPROVED':
|
||||
return {
|
||||
title: zh ? '技能推广通过' : 'Promotion approved',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 推广已通过。` : `${skillName}${versionSuffix} promotion was approved.`) : '',
|
||||
}
|
||||
case 'PROMOTION_REJECTED':
|
||||
return {
|
||||
title: zh ? '技能推广驳回' : 'Promotion rejected',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 推广未通过。` : `${skillName}${versionSuffix} promotion was rejected.`) : '',
|
||||
}
|
||||
case 'REPORT_SUBMITTED':
|
||||
return {
|
||||
title: zh ? '技能举报提交' : 'Report submitted',
|
||||
description: skillName ? (zh ? `${skillName} 收到新的举报。` : `${skillName} received a new report.`) : '',
|
||||
}
|
||||
case 'REPORT_RESOLVED':
|
||||
return {
|
||||
title: zh ? '技能举报已处理' : 'Report resolved',
|
||||
description: skillName ? (zh ? `${skillName} 的举报已处理。` : `${skillName} report has been resolved.`) : '',
|
||||
}
|
||||
case 'SKILL_PUBLISHED':
|
||||
return {
|
||||
title: zh ? '技能发布成功' : 'Skill published',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 已发布。` : `${skillName}${versionSuffix} was published.`) : '',
|
||||
}
|
||||
case 'SUBSCRIPTION_NEW_VERSION':
|
||||
return {
|
||||
title: zh ? '订阅技能更新' : 'Subscribed skill updated',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 发布了新版本。` : `${skillName}${versionSuffix} published a new version.`) : '',
|
||||
}
|
||||
case 'SUBSCRIPTION_VERSION_YANKED':
|
||||
return {
|
||||
title: zh ? '订阅技能版本撤回' : 'Subscribed skill version yanked',
|
||||
description: skillName ? (zh ? `${skillName}${versionSuffix} 版本已撤回。` : `${skillName}${versionSuffix} version was yanked.`) : '',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
title: item.title,
|
||||
description: '',
|
||||
}
|
||||
if (!localizedEvents.has(item.eventType)) {
|
||||
return { title: item.title, description: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
title: t(`notificationEvents.${item.eventType}.title`),
|
||||
description: skillName
|
||||
? t(`notificationEvents.${item.eventType}.description`, { skillName, versionSuffix })
|
||||
: '',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
const api = vi.hoisted(() => ({ status: vi.fn(), delete: vi.fn(), submit: vi.fn(), retry: vi.fn(), appeal: vi.fn() }))
|
||||
vi.mock('@/api/client', () => ({ skillAvatarApi: api, buildApiUrl: (url: string) => url }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
|
||||
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string, options?: { time?: string }) => options?.time ? `${key}: ${options.time}` : key, i18n: { language: 'ko-KR', resolvedLanguage: 'ko' } }) }))
|
||||
import { SkillAvatarManager } from './skill-avatar-manager'
|
||||
|
||||
describe('SkillAvatarManager appeal', () => {
|
||||
|
|
@ -26,13 +26,14 @@ describe('SkillAvatarManager appeal', () => {
|
|||
})
|
||||
|
||||
it('disables paid retry during cooldown and explains when automatic retries stop', async () => {
|
||||
const nextRetry = new Date(Date.now() + 3600000)
|
||||
api.status.mockResolvedValue({ avatarUrl: null, candidateId: 7, candidateStatus: 'PENDING',
|
||||
machineStatus: 'ERROR', reviewStatus: 'NOT_REQUIRED', candidateUrl: null, reviewReason: null,
|
||||
machineNextRetryAt: new Date(Date.now() + 3600000).toISOString(), automaticRetryExhausted: true })
|
||||
machineNextRetryAt: nextRetry.toISOString(), automaticRetryExhausted: true })
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(<QueryClientProvider client={client}><SkillAvatarManager namespace="team" slug="foo" /></QueryClientProvider>)
|
||||
|
||||
expect(await screen.findByText('skillAvatar.retryAfter')).toBeTruthy()
|
||||
expect(await screen.findByText(`skillAvatar.retryAfter: ${nextRetry.toLocaleString('ko')}`)).toBeTruthy()
|
||||
expect(screen.getByText('skillAvatar.automaticRetryStopped')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'skillAvatar.retry' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { buildApiUrl, skillAvatarApi } from '@/api/client'
|
|||
import { Button } from '@/shared/ui/button'
|
||||
|
||||
export function SkillAvatarManager({ namespace, slug, disabled = false, previewOnly = false }: { namespace: string; slug: string; disabled?: boolean; previewOnly?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const key = ['skill-avatar', namespace, slug]
|
||||
const status = useQuery({ queryKey: key, queryFn: () => skillAvatarApi.status(namespace, slug), enabled: !!namespace && !!slug, staleTime: 0 })
|
||||
|
|
@ -41,7 +41,7 @@ export function SkillAvatarManager({ namespace, slug, disabled = false, previewO
|
|||
</div>
|
||||
{candidate?.candidateStatus && <p role="status" className="text-sm">{t('skillAvatar.status')}: {t(`skillAvatar.states.${candidate.candidateStatus}`)}</p>}
|
||||
{candidate?.reviewReason && <p className="text-sm">{candidate.reviewReason}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && retryCoolingDown && <p className="text-sm text-muted-foreground">{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString() })}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && retryCoolingDown && <p className="text-sm text-muted-foreground">{t('skillAvatar.retryAfter', { time: new Date(candidate.machineNextRetryAt!).toLocaleString(i18n.resolvedLanguage || i18n.language) })}</p>}
|
||||
{candidate?.machineStatus === 'ERROR' && candidate.automaticRetryExhausted && <p className="text-sm text-muted-foreground">{t('skillAvatar.automaticRetryStopped')}</p>}
|
||||
{!previewOnly && <div className="flex flex-wrap gap-2">
|
||||
{candidate?.candidateStatus === 'PENDING' && candidate.machineStatus === 'BLOCKED'
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ vi.mock('@/features/auth/use-auth', () => ({
|
|||
}),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
vi.mock('react-i18next', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('react-i18next')>(),
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { language: 'en' },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import en from '@/i18n/locales/en.json'
|
||||
import ko from '@/i18n/locales/ko.json'
|
||||
import ru from '@/i18n/locales/ru.json'
|
||||
import zh from '@/i18n/locales/zh.json'
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ describe('skill reviews', () => {
|
|||
})
|
||||
|
||||
it('keeps review interaction copy in every supported locale', () => {
|
||||
for (const locale of [en, zh, ru]) {
|
||||
for (const locale of [en, zh, ru, ko]) {
|
||||
expect(locale.skillReviews.ratingDisplay).toBeTruthy()
|
||||
expect(locale.skillReviews.ratingOption).toBeTruthy()
|
||||
expect(locale.skillReviews.reviewTextLabel).toBeTruthy()
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ vi.mock('./locales/zh.json', () => ({
|
|||
default: { greeting: '你好' },
|
||||
}))
|
||||
|
||||
vi.mock('./locales/ko.json', () => ({
|
||||
default: { greeting: '안녕하세요' },
|
||||
}))
|
||||
|
||||
vi.mock('./locales/ru.json', () => ({
|
||||
default: { greeting: 'Привет' },
|
||||
}))
|
||||
|
|
@ -58,9 +62,10 @@ describe('i18n config', () => {
|
|||
expect(initOptions.detection.caches).toEqual(['localStorage'])
|
||||
})
|
||||
|
||||
it('registers english, russian and chinese resource bundles', () => {
|
||||
it('registers english, korean, russian and chinese resource bundles', () => {
|
||||
const initOptions = initMock.mock.calls[0][0]
|
||||
expect(initOptions.resources).toHaveProperty('en')
|
||||
expect(initOptions.resources.ko.translation).toEqual({ greeting: '안녕하세요' })
|
||||
expect(initOptions.resources).toHaveProperty('ru')
|
||||
expect(initOptions.resources).toHaveProperty('zh')
|
||||
expect(initOptions.resources.en).toHaveProperty('translation')
|
||||
|
|
|
|||
|
|
@ -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 ko from './locales/ko.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ i18n
|
|||
.init({
|
||||
resources: {
|
||||
en: { translation: en },
|
||||
ko: { translation: ko },
|
||||
ru: { translation: ru },
|
||||
zh: { translation: zh },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ko from './locales/ko.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
|
|
@ -10,7 +11,7 @@ const dispositionCodes = [
|
|||
|
||||
describe('deep discovery drawer disposition', () => {
|
||||
it('describes every repository detail result in each locale', () => {
|
||||
for (const locale of [zh, en, ru]) {
|
||||
for (const locale of [zh, en, ru, ko]) {
|
||||
expect(locale.deepDiscovery.processingStatus).toBeTruthy()
|
||||
for (const code of dispositionCodes) {
|
||||
expect(locale.deepDiscovery.reason[code]).toBeTruthy()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ko from './locales/ko.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
|
|
@ -8,6 +9,7 @@ describe('GitHub sync status locales', () => {
|
|||
const expected = Object.keys(zh.githubSync.codes).sort()
|
||||
expect(Object.keys(en.githubSync.codes).sort()).toEqual(expected)
|
||||
expect(Object.keys(ru.githubSync.codes).sort()).toEqual(expected)
|
||||
expect(Object.keys(ko.githubSync.codes).sort()).toEqual(expected)
|
||||
})
|
||||
|
||||
it('describes known acquisition and archive failures for administrators', () => {
|
||||
|
|
@ -20,6 +22,7 @@ describe('GitHub sync status locales', () => {
|
|||
expect(zh.githubSync.codes[code]).not.toBe(code)
|
||||
expect(en.githubSync.codes[code]).toBeTruthy()
|
||||
expect(ru.githubSync.codes[code]).toBeTruthy()
|
||||
expect(ko.githubSync.codes[code]).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
51
web/src/i18n/ko-locale.test.ts
Normal file
51
web/src/i18n/ko-locale.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { createInstance } from 'i18next'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ko from './locales/ko.json'
|
||||
|
||||
function flatten(value: unknown, prefix = ''): Record<string, string> {
|
||||
if (typeof value === 'string') return { [prefix]: value }
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([key, child]) =>
|
||||
Object.entries(flatten(child, prefix ? `${prefix}.${key}` : key)),
|
||||
))
|
||||
}
|
||||
|
||||
const pluralSuffix = /_(zero|one|two|few|many|other)$/
|
||||
const english = flatten(en)
|
||||
const korean = flatten(ko)
|
||||
const normalizedKeys = (keys: string[]) => [...new Set(keys.map((key) => key.replace(pluralSuffix, '_plural')))].sort()
|
||||
const placeholders = (text: string) => [...text.matchAll(/\{\{[^}]+\}\}/g)].map(([match]) => match).sort()
|
||||
const tags = (text: string) => [...text.matchAll(/<\/?[\w]+\s*\/?\s*>/g)].map(([match]) => match).sort()
|
||||
|
||||
describe('Korean locale', () => {
|
||||
it('covers every English key, allowing language-specific plural categories', () => {
|
||||
expect(normalizedKeys(Object.keys(korean))).toEqual(normalizedKeys(Object.keys(english)))
|
||||
})
|
||||
|
||||
it('preserves interpolation variables and markup without empty translations', () => {
|
||||
for (const [key, value] of Object.entries(korean)) {
|
||||
const reference = english[key] ?? english[key.replace(pluralSuffix, '_other')]
|
||||
expect(reference, key).toBeDefined()
|
||||
expect(placeholders(value), key).toEqual(placeholders(reference))
|
||||
expect(tags(value), key).toEqual(tags(reference))
|
||||
// The existing pagination suffix is deliberately empty in English.
|
||||
if (reference.trim()) expect(value.trim(), key).not.toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps executable installation commands and registry placeholders intact', () => {
|
||||
expect(ko.landing.quickStart.cli.command).toBe(en.landing.quickStart.cli.command)
|
||||
expect(ko.landing.quickStart.human.command).toBe(en.landing.quickStart.human.command)
|
||||
expect(ko.landing.quickStart.human.commandTemplate).toBe(en.landing.quickStart.human.commandTemplate)
|
||||
})
|
||||
|
||||
it('uses Korean plural rules for zero, one, and multiple reviews without English fallback', async () => {
|
||||
const instance = createInstance()
|
||||
await instance.init({ lng: 'ko-KR', fallbackLng: 'en', resources: { en: { translation: en }, ko: { translation: ko } } })
|
||||
for (const count of [0, 1, 2, 21, 100]) {
|
||||
const result = instance.t('skillReviews.count', { count, returnDetails: true })
|
||||
expect(result.res, String(count)).toBe(`리뷰 ${count}개`)
|
||||
expect(result.usedLng).toBe('ko')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import skillGuideTemplate from '../docs/skill.md.template?raw'
|
||||
import en from './locales/en.json'
|
||||
import ko from './locales/ko.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ describe('landing quick start locales', () => {
|
|||
zh.skillDetail.installForAgent.prompt,
|
||||
en.skillDetail.installForAgent.prompt,
|
||||
ru.skillDetail.installForAgent.prompt,
|
||||
ko.skillDetail.installForAgent.prompt,
|
||||
]) {
|
||||
expect(prompt).toContain('{{guideUrl}}')
|
||||
expect(prompt).toContain('{{skill}}')
|
||||
|
|
|
|||
54
web/src/i18n/language-detection.test.ts
Normal file
54
web/src/i18n/language-detection.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function initialize() {
|
||||
const { default: i18n } = await import('./config')
|
||||
if (!i18n.isInitialized) await new Promise<void>((resolve) => i18n.on('initialized', () => resolve()))
|
||||
return i18n
|
||||
}
|
||||
|
||||
describe('browser language detection with real i18next', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
localStorage.clear()
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['ko-KR'])
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('ko-KR')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('resolves a Korean browser locale to the Korean resource', async () => {
|
||||
const i18n = await initialize()
|
||||
expect(i18n.resolvedLanguage).toBe('ko')
|
||||
expect(i18n.t('nav.home')).toBe('스킬 센터')
|
||||
})
|
||||
|
||||
it('respects an explicit English preference over the Korean browser', async () => {
|
||||
localStorage.setItem('i18nextLng', 'en')
|
||||
const i18n = await initialize()
|
||||
expect(i18n.resolvedLanguage).toBe('en')
|
||||
})
|
||||
|
||||
it('persists a Korean selection and restores it on a fresh initialization', async () => {
|
||||
vi.spyOn(navigator, 'languages', 'get').mockReturnValue(['en-US'])
|
||||
vi.spyOn(navigator, 'language', 'get').mockReturnValue('en-US')
|
||||
const i18n = await initialize()
|
||||
expect(i18n.resolvedLanguage).toBe('en')
|
||||
await i18n.changeLanguage('ko')
|
||||
expect(localStorage.getItem('i18nextLng')).toBe('ko')
|
||||
vi.resetModules()
|
||||
const restored = await initialize()
|
||||
expect(restored.resolvedLanguage).toBe('ko')
|
||||
expect(restored.t('nav.home')).toBe('스킬 센터')
|
||||
})
|
||||
|
||||
it('keeps English fallback for an unsupported language', async () => {
|
||||
localStorage.setItem('i18nextLng', 'fr-FR')
|
||||
const i18n = await initialize()
|
||||
expect(i18n.resolvedLanguage).toBe('en')
|
||||
expect(i18n.t('nav.home')).toBe('Skill Center')
|
||||
})
|
||||
})
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
"evidence": "Evidence"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"expand": "Expand details",
|
||||
"collapse": "Collapse details"
|
||||
},
|
||||
|
|
@ -379,6 +380,7 @@
|
|||
"enterpriseContinue": "Log in with work account",
|
||||
"switchToPersonal": "Use personal account instead",
|
||||
"agreementPrefix": "By logging in, you agree to our",
|
||||
"agreement": "By logging in, you agree to our <terms>Terms of Service</terms> and <privacy>Privacy Policy</privacy>",
|
||||
"terms": "Terms of Service",
|
||||
"and": "and",
|
||||
"privacy": "Privacy Policy"
|
||||
|
|
@ -2208,6 +2210,9 @@
|
|||
}
|
||||
},
|
||||
"footer": {
|
||||
"license": "License",
|
||||
"contributing": "Contributing",
|
||||
"changelog": "Changelog",
|
||||
"product": "Product",
|
||||
"marketplace": "Skill marketplace",
|
||||
"publish": "Publish skill",
|
||||
|
|
@ -3240,5 +3245,57 @@
|
|||
"scanPASSED": "Security scan passed.",
|
||||
"scanFAILED": "Security scan did not pass. Current visibility is unchanged. Withdraw and retry or upload a fixed version.",
|
||||
"pending": "Pending visibility request: {{visibility}}. Complete or withdraw it in My review progress."
|
||||
},
|
||||
"relativeTime": {
|
||||
"justNow": "just now",
|
||||
"minutes": "{{count}}m",
|
||||
"hours": "{{count}}h",
|
||||
"days": "{{count}}d"
|
||||
},
|
||||
"notificationEvents": {
|
||||
"REVIEW_SUBMITTED": {
|
||||
"title": "Review submitted",
|
||||
"description": "{{skillName}}{{versionSuffix}} was submitted for review."
|
||||
},
|
||||
"REVIEW_APPROVED": {
|
||||
"title": "Review approved",
|
||||
"description": "{{skillName}}{{versionSuffix}} was approved."
|
||||
},
|
||||
"REVIEW_REJECTED": {
|
||||
"title": "Review rejected",
|
||||
"description": "{{skillName}}{{versionSuffix}} was rejected."
|
||||
},
|
||||
"PROMOTION_SUBMITTED": {
|
||||
"title": "Promotion submitted",
|
||||
"description": "{{skillName}}{{versionSuffix}} was submitted for promotion."
|
||||
},
|
||||
"PROMOTION_APPROVED": {
|
||||
"title": "Promotion approved",
|
||||
"description": "{{skillName}}{{versionSuffix}} promotion was approved."
|
||||
},
|
||||
"PROMOTION_REJECTED": {
|
||||
"title": "Promotion rejected",
|
||||
"description": "{{skillName}}{{versionSuffix}} promotion was rejected."
|
||||
},
|
||||
"REPORT_SUBMITTED": {
|
||||
"title": "Report submitted",
|
||||
"description": "{{skillName}} received a new report."
|
||||
},
|
||||
"REPORT_RESOLVED": {
|
||||
"title": "Report resolved",
|
||||
"description": "{{skillName}} report has been resolved."
|
||||
},
|
||||
"SKILL_PUBLISHED": {
|
||||
"title": "Skill published",
|
||||
"description": "{{skillName}}{{versionSuffix}} was published."
|
||||
},
|
||||
"SUBSCRIPTION_NEW_VERSION": {
|
||||
"title": "Subscribed skill updated",
|
||||
"description": "{{skillName}}{{versionSuffix}} published a new version."
|
||||
},
|
||||
"SUBSCRIPTION_VERSION_YANKED": {
|
||||
"title": "Subscribed skill version yanked",
|
||||
"description": "{{skillName}}{{versionSuffix}} version was yanked."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
156
web/src/i18n/locales/ko-legal.json
Normal file
156
web/src/i18n/locales/ko-legal.json
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
{
|
||||
"terms": {
|
||||
"eyebrow": "법률 안내",
|
||||
"title": "이용약관",
|
||||
"summary": "본 약관은 SkillHub의 탐색, 게시, 검토, 다운로드, 계정 관리 및 관련 API 서비스에 대한 접근과 이용에 적용됩니다. 서비스를 이용하면 본 약관에 동의한 것으로 봅니다.",
|
||||
"lastUpdated": "최종 업데이트: 2026년 3월 14일",
|
||||
"note": "조직에서 SkillHub를 비공개로 배포하여 운영하는 경우, 본 기본 약관에 더하여 내부 규칙, 정보 보안 요건 또는 허용되는 이용 정책을 적용할 수 있습니다.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "1. 동의 및 적용 범위",
|
||||
"paragraphs": [
|
||||
"SkillHub에 접근하거나 가입하거나 이용하면 본 이용약관, 관련 개인정보처리방침 및 현재 인스턴스의 운영 규칙에 동의한 것으로 봅니다.",
|
||||
"팀, 회사 또는 기타 조직을 대신하여 SkillHub를 이용하는 경우, 해당 조직을 대신하여 본 약관에 동의할 권한이 있음을 진술합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "2. 계정 및 접근 보안",
|
||||
"paragraphs": [],
|
||||
"bullets": [
|
||||
"정확한 최신 계정 정보를 제공하고 자격 증명, OAuth 세션, API 토큰을 보호해야 합니다.",
|
||||
"게시, 검토, 다운로드, 토큰 생성, 네임스페이스 관리를 포함하여 계정을 통해 수행되는 활동에 대한 책임은 본인에게 있습니다.",
|
||||
"무단 접근, 자격 증명 유출 또는 기타 보안 사고를 알게 되면 인스턴스 관리자나 운영자에게 즉시 알려야 합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "3. 스킬, 네임스페이스 및 사용자 콘텐츠",
|
||||
"paragraphs": [
|
||||
"업로드하거나 제출한 콘텐츠에 대한 권리는 본인에게 유지됩니다. 다만 서비스 운영에 필요한 범위에서 해당 콘텐츠를 호스팅, 저장, 복사, 처리, 표시, 배포할 수 있는 비독점적이고 전 세계적이며 재허가 가능한 라이선스를 SkillHub에 부여합니다.",
|
||||
"제출하는 스킬 패키지, README 파일, 설명, 스크린샷, 검토 의견 및 기타 자료에 대한 책임은 본인에게 있으며, 이를 제공할 권리가 있음을 진술합니다."
|
||||
],
|
||||
"bullets": [
|
||||
"악성 소프트웨어, 불법 자료, 권리를 침해하는 자료, 기만적 자료 또는 사용자를 오도할 목적으로 작성된 콘텐츠를 업로드해서는 안 됩니다.",
|
||||
"타인을 사칭하거나 권한 없이 네임스페이스를 차지하거나 검토, 심사 또는 접근 제어를 우회해서는 안 됩니다.",
|
||||
"스킬을 다운로드하거나 재배포할 때는 해당 스킬의 라이선스 조건, 제3자 의존성의 라이선스 및 관련 법률도 준수해야 합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "4. 검토, 거버넌스 및 조치",
|
||||
"paragraphs": [
|
||||
"SkillHub는 서비스의 보안, 규정 준수 및 품질을 보호하기 위해 콘텐츠를 검토하고 승인, 반려, 숨김, 제거, 버전 배포 철회, 접근 제한 또는 계정 정지 등의 조치를 취할 수 있습니다.",
|
||||
"악용, 권리 침해, 보안 위험, 불법 행위 또는 기타 위반이 의심되는 경우 플랫폼 또는 인스턴스 관리자는 로그를 보존하고 적절한 조치를 취할 수 있습니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "5. 다운로드, API 및 허용되는 이용",
|
||||
"paragraphs": [],
|
||||
"bullets": [
|
||||
"서비스의 안정성을 방해하거나 인증, 요청 제한, 보안 제어 또는 권한 경계를 우회해서는 안 됩니다.",
|
||||
"파괴적인 방식으로 SkillHub를 대상으로 스크래핑, 부하 테스트, 스캔 또는 자동화를 수행하거나 서비스를 이용하여 바이러스, 트로이 목마 또는 기타 악성 페이로드를 배포해서는 안 됩니다.",
|
||||
"SkillHub를 통해 다운로드한 스킬은 해당 게시자가 제공하며 각 스킬의 라이선스와 위험 안내에 따라 사용해야 합니다. 호환성, 보안 및 규정 준수를 평가할 책임은 본인에게 있습니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "6. 오픈소스 구성 요소, 지식재산 및 브랜드",
|
||||
"paragraphs": [
|
||||
"SkillHub에는 Apache License 2.0 또는 기타 제3자 라이선스가 적용되는 오픈소스 구성 요소가 포함될 수 있으며, 해당 구성 요소에는 각 라이선스 조건이 계속 적용됩니다.",
|
||||
"별도로 명시하지 않는 한 SkillHub의 상표, 브랜드 자산, 인터페이스 디자인 및 사이트 콘텐츠는 각 소유자의 재산이며 서비스 이용으로 이전되지 않습니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "7. 가용성 및 보증 부인",
|
||||
"paragraphs": [
|
||||
"검색, 다운로드, 검토 워크플로, 로그인 방식 및 API 기능을 포함한 서비스의 일부를 언제든지 수정, 업데이트, 제한 또는 중단할 수 있으며, 지속적인 이용 가능성을 보장하지 않습니다.",
|
||||
"법률이 허용하는 최대 범위에서 SkillHub는 상품성, 특정 목적에의 적합성, 비침해성, 중단 없는 이용 가능성 또는 정확성에 대한 보증을 포함하여 명시적이거나 묵시적인 보증 없이 ‘있는 그대로’ 및 ‘이용 가능한 상태로’ 제공됩니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "8. 책임 제한",
|
||||
"paragraphs": [
|
||||
"법률이 허용하는 최대 범위에서 SkillHub와 운영자는 간접적, 부수적, 특별, 결과적 또는 징벌적 손해나 이익, 데이터 또는 영업권의 손실에 대해 책임을 지지 않습니다.",
|
||||
"서비스 이용, 제출한 콘텐츠 또는 본 약관 위반으로 청구가 발생하는 경우 법률이 허용하는 범위에서 책임을 부담하고 문제 해결에 협력하는 데 동의합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "9. 종료, 변경 및 문의",
|
||||
"paragraphs": [
|
||||
"언제든지 SkillHub 이용을 중단할 수 있습니다. 약관을 위반하거나 보안 위험을 초래하거나 법률상 필요한 경우 접근을 일시 중지하거나 종료할 수 있습니다.",
|
||||
"본 약관은 수시로 업데이트될 수 있습니다. 업데이트 후에도 계속 이용하면 개정된 약관에 동의한 것으로 봅니다. 문의하려면 현재 인스턴스에서 제공하는 문서, 커뮤니티 또는 관리자 채널을 이용하세요."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"privacy": {
|
||||
"eyebrow": "법률 안내",
|
||||
"title": "개인정보처리방침",
|
||||
"summary": "본 방침은 SkillHub가 스킬 레지스트리, 게시, 검토, 다운로드, 계정 및 관련 API 서비스를 제공할 때 정보를 수집, 이용, 공유 및 보호하는 방식을 설명합니다.",
|
||||
"lastUpdated": "최종 업데이트: 2026년 3월 14일",
|
||||
"note": "비공개로 배포된 SkillHub 인스턴스를 이용하는 경우, 해당 운영자는 자체 내부 정책에 따라 데이터를 처리할 수 있으며 해당 환경에서 처리하는 데이터에 대해 독립적으로 행동할 수 있습니다.",
|
||||
"sections": [
|
||||
{
|
||||
"title": "1. 적용 범위",
|
||||
"paragraphs": [
|
||||
"본 방침은 SkillHub 웹사이트, 웹 콘솔, 공개 스킬 페이지, 로그인 절차, 기기 인증 절차 및 관련 API와 서비스에 적용됩니다.",
|
||||
"스킬 탐색, 버전 게시, 검토 참여, 토큰 생성 또는 콘텐츠 다운로드 시 본인과 관련된 정보를 처리하는 방식을 설명합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "2. 수집하는 정보",
|
||||
"paragraphs": [],
|
||||
"bullets": [
|
||||
"사용자 이름, 이메일, 아바타, OAuth 제공자 식별자, 플랫폼 역할, 네임스페이스 소속 등의 계정 및 신원 정보.",
|
||||
"스킬 패키지, README 파일, 릴리스 노트, 네임스페이스 프로필, 평점, 즐겨찾기, 검토 의견 등 제출한 콘텐츠.",
|
||||
"IP 주소, 브라우저 또는 기기 정보, 요청 로그, 다운로드 활동, 로그인 이벤트, API 토큰 메타데이터, 오류 로그, 감사 로그 등의 이용 및 보안 정보."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "3. 정보 이용 방식",
|
||||
"paragraphs": [],
|
||||
"bullets": [
|
||||
"로그인, 세션 관리, 접근 제어, 기기 인증, 계정 보안 및 기본 지원 제공.",
|
||||
"공개 스킬 페이지 표시와 검색, 다운로드, 평점, 즐겨찾기, 네임스페이스 협업 및 거버넌스 워크플로 제공.",
|
||||
"검토, 요청 제한, 악용 방지, 디버깅, 성능 분석 및 서비스 개선 수행.",
|
||||
"필요한 경우 보안, 정책 변경 또는 서비스 가용성과 관련된 중요 안내 전달."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "4. 공개 정보 및 공유",
|
||||
"paragraphs": [
|
||||
"공개로 게시한 스킬, 릴리스 노트, 네임스페이스 이름, 공개 평점 및 일부 프로필 정보는 다른 사용자나 방문자에게 표시될 수 있습니다.",
|
||||
"개인정보를 판매하지 않습니다. 호스팅, 인증, 모니터링 또는 규정 준수 지원을 제공하는 데 필요하거나 법률을 준수하고 서비스와 사용자를 보호하기 위해 정보를 공유할 수 있습니다.",
|
||||
"비공개 배포의 경우 인스턴스 운영자는 자체 내부 거버넌스 및 규정 준수 요건에 따라 데이터에 접근하거나 처리하거나 보관할 수 있습니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "5. 데이터 보관",
|
||||
"paragraphs": [
|
||||
"서비스 운영에 필요한 기간 동안 계정 정보, 스킬 메타데이터, 검토 기록, 보안 로그를 보관하며 법률, 감사 또는 규정 준수 목적에 필요한 경우 더 오래 보관할 수 있습니다.",
|
||||
"폐기된 API 토큰은 더 이상 작동하지 않지만 관련 보안 및 감사 기록은 남을 수 있습니다. 스킬을 삭제하거나 숨긴 후에도 백업이나 로그에 남은 사본은 합리적인 기간 동안 유지될 수 있습니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "6. 선택권 및 권리",
|
||||
"paragraphs": [],
|
||||
"bullets": [
|
||||
"계정 정보를 업데이트하고 비밀번호를 변경하며 API 토큰을 폐기하거나 다시 만들 수 있습니다.",
|
||||
"인스턴스 관리자에게 본인 관련 정보의 내보내기, 정정 또는 삭제를 요청할 수 있습니다. 다만 보안, 감사 또는 규정 준수를 위해 일부 기록을 보관해야 할 수 있습니다.",
|
||||
"비공개로 배포된 인스턴스에서는 일반적으로 해당 인스턴스 운영자에게 먼저 개인정보 관련 요청을 해야 합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "7. 보안 조치",
|
||||
"paragraphs": [
|
||||
"접근 제어, 인증, 감사, 토큰 관리, 전송 보안 등의 합리적인 기술적·조직적 보호 조치를 사용합니다.",
|
||||
"어떤 인터넷 서비스도 절대적인 보안을 보장할 수 없습니다. 자격 증명을 보호하고 의심스러운 접근, 유출 또는 오용을 발견하면 인스턴스 운영자에게 즉시 알려야 합니다."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "8. 방침 업데이트 및 문의",
|
||||
"paragraphs": [
|
||||
"제품, 법률 요건 또는 운영 방식이 변경되면 본 방침을 업데이트할 수 있습니다. 최신 버전은 서비스에 게시되며 업데이트 날짜로 구분됩니다.",
|
||||
"문의하려면 현재 SkillHub 인스턴스에서 제공하는 문서, 커뮤니티, 지원 채널 또는 관리자 연락처를 이용하세요."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
3582
web/src/i18n/locales/ko.json
Normal file
3582
web/src/i18n/locales/ko.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,6 +6,7 @@
|
|||
"evidence": "Доказательства"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"expand": "Развернуть подробности",
|
||||
"collapse": "Свернуть подробности"
|
||||
},
|
||||
|
|
@ -379,6 +380,7 @@
|
|||
"enterpriseContinue": "Войти с рабочим аккаунтом",
|
||||
"switchToPersonal": "Использовать личный аккаунт",
|
||||
"agreementPrefix": "Входя в систему, вы соглашаетесь с",
|
||||
"agreement": "Входя в систему, вы соглашаетесь с <terms>Условиями использования</terms> и <privacy>Политикой конфиденциальности</privacy>",
|
||||
"terms": "Условиями использования",
|
||||
"and": "и",
|
||||
"privacy": "Политикой конфиденциальности"
|
||||
|
|
@ -2099,6 +2101,9 @@
|
|||
"title": "Файлы"
|
||||
},
|
||||
"footer": {
|
||||
"license": "Лицензия",
|
||||
"contributing": "Участие в проекте",
|
||||
"changelog": "История изменений",
|
||||
"product": "Продукт",
|
||||
"marketplace": "Каталог скиллов",
|
||||
"publish": "Опубликовать скилл",
|
||||
|
|
@ -3242,5 +3247,57 @@
|
|||
"scanPASSED": "Проверка безопасности пройдена.",
|
||||
"scanFAILED": "Проверка безопасности не пройдена. Доступ не изменён. Отзовите заявку и повторите попытку или загрузите исправленную версию.",
|
||||
"pending": "Ожидающая заявка: {{visibility}}. Завершите или отзовите её в разделе проверок."
|
||||
},
|
||||
"relativeTime": {
|
||||
"justNow": "только что",
|
||||
"minutes": "{{count}} мин",
|
||||
"hours": "{{count}} ч",
|
||||
"days": "{{count}} д"
|
||||
},
|
||||
"notificationEvents": {
|
||||
"REVIEW_SUBMITTED": {
|
||||
"title": "Проверка отправлена",
|
||||
"description": "{{skillName}}{{versionSuffix}} отправлен на проверку."
|
||||
},
|
||||
"REVIEW_APPROVED": {
|
||||
"title": "Проверка одобрена",
|
||||
"description": "{{skillName}}{{versionSuffix}} одобрен."
|
||||
},
|
||||
"REVIEW_REJECTED": {
|
||||
"title": "Проверка отклонена",
|
||||
"description": "{{skillName}}{{versionSuffix}} отклонён."
|
||||
},
|
||||
"PROMOTION_SUBMITTED": {
|
||||
"title": "Продвижение отправлено",
|
||||
"description": "{{skillName}}{{versionSuffix}} отправлен на продвижение."
|
||||
},
|
||||
"PROMOTION_APPROVED": {
|
||||
"title": "Продвижение одобрено",
|
||||
"description": "Продвижение {{skillName}}{{versionSuffix}} одобрено."
|
||||
},
|
||||
"PROMOTION_REJECTED": {
|
||||
"title": "Продвижение отклонено",
|
||||
"description": "Продвижение {{skillName}}{{versionSuffix}} отклонено."
|
||||
},
|
||||
"REPORT_SUBMITTED": {
|
||||
"title": "Жалоба на навык отправлена",
|
||||
"description": "Получена новая жалоба на {{skillName}}."
|
||||
},
|
||||
"REPORT_RESOLVED": {
|
||||
"title": "Жалоба обработана",
|
||||
"description": "Жалоба на {{skillName}} обработана."
|
||||
},
|
||||
"SKILL_PUBLISHED": {
|
||||
"title": "Навык опубликован",
|
||||
"description": "{{skillName}}{{versionSuffix}} опубликован."
|
||||
},
|
||||
"SUBSCRIPTION_NEW_VERSION": {
|
||||
"title": "Обновление подписанного навыка",
|
||||
"description": "Опубликована новая версия {{skillName}}{{versionSuffix}}."
|
||||
},
|
||||
"SUBSCRIPTION_VERSION_YANKED": {
|
||||
"title": "Версия подписанного навыка отозвана",
|
||||
"description": "Версия {{skillName}}{{versionSuffix}} отозвана."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"evidence": "证据"
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"expand": "展开详情",
|
||||
"collapse": "收起详情"
|
||||
},
|
||||
|
|
@ -379,6 +380,7 @@
|
|||
"enterpriseContinue": "通过企业账号登录",
|
||||
"switchToPersonal": "改用个人账号",
|
||||
"agreementPrefix": "登录即表示你同意我们的",
|
||||
"agreement": "登录即表示你同意我们的<terms>服务条款</terms>和<privacy>隐私政策</privacy>",
|
||||
"terms": "服务条款",
|
||||
"and": "和",
|
||||
"privacy": "隐私政策"
|
||||
|
|
@ -2207,6 +2209,9 @@
|
|||
}
|
||||
},
|
||||
"footer": {
|
||||
"license": "许可证",
|
||||
"contributing": "参与贡献",
|
||||
"changelog": "更新日志",
|
||||
"product": "产品",
|
||||
"marketplace": "技能市场",
|
||||
"publish": "发布技能",
|
||||
|
|
@ -3239,5 +3244,57 @@
|
|||
"scanPASSED": "安全扫描已通过。",
|
||||
"scanFAILED": "安全扫描未通过,当前范围保持不变。请撤回后重试或上传修复版本。",
|
||||
"pending": "已有待审核范围申请:{{visibility}}。请到“我的审核进度”完成或撤回。"
|
||||
},
|
||||
"relativeTime": {
|
||||
"justNow": "刚刚",
|
||||
"minutes": "{{count}}分钟",
|
||||
"hours": "{{count}}小时",
|
||||
"days": "{{count}}天"
|
||||
},
|
||||
"notificationEvents": {
|
||||
"REVIEW_SUBMITTED": {
|
||||
"title": "技能审核提交",
|
||||
"description": "{{skillName}}{{versionSuffix}} 已提交审核。"
|
||||
},
|
||||
"REVIEW_APPROVED": {
|
||||
"title": "技能审核通过",
|
||||
"description": "{{skillName}}{{versionSuffix}} 已审核通过。"
|
||||
},
|
||||
"REVIEW_REJECTED": {
|
||||
"title": "技能审核驳回",
|
||||
"description": "{{skillName}}{{versionSuffix}} 审核未通过。"
|
||||
},
|
||||
"PROMOTION_SUBMITTED": {
|
||||
"title": "技能推广提交",
|
||||
"description": "{{skillName}}{{versionSuffix}} 已提交推广。"
|
||||
},
|
||||
"PROMOTION_APPROVED": {
|
||||
"title": "技能推广通过",
|
||||
"description": "{{skillName}}{{versionSuffix}} 推广已通过。"
|
||||
},
|
||||
"PROMOTION_REJECTED": {
|
||||
"title": "技能推广驳回",
|
||||
"description": "{{skillName}}{{versionSuffix}} 推广未通过。"
|
||||
},
|
||||
"REPORT_SUBMITTED": {
|
||||
"title": "技能举报提交",
|
||||
"description": "{{skillName}} 收到新的举报。"
|
||||
},
|
||||
"REPORT_RESOLVED": {
|
||||
"title": "技能举报已处理",
|
||||
"description": "{{skillName}} 的举报已处理。"
|
||||
},
|
||||
"SKILL_PUBLISHED": {
|
||||
"title": "技能发布成功",
|
||||
"description": "{{skillName}}{{versionSuffix}} 已发布。"
|
||||
},
|
||||
"SUBSCRIPTION_NEW_VERSION": {
|
||||
"title": "订阅技能更新",
|
||||
"description": "{{skillName}}{{versionSuffix}} 发布了新版本。"
|
||||
},
|
||||
"SUBSCRIPTION_VERSION_YANKED": {
|
||||
"title": "订阅技能版本撤回",
|
||||
"description": "{{skillName}}{{versionSuffix}} 版本已撤回。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ko from './locales/ko.json'
|
||||
import zh from './locales/zh.json'
|
||||
import ru from './locales/ru.json'
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ describe('security audit locales', () => {
|
|||
})
|
||||
|
||||
it('uses the precise safe verdict wording in all locales', () => {
|
||||
expect(ko.securityAudit.verdict.SAFE).toBe('고위험 항목이 발견되지 않음')
|
||||
expect(en.securityAudit.verdict.SAFE).toBe('No high-risk findings')
|
||||
expect(zh.securityAudit.verdict.SAFE).toBe('未发现高风险问题')
|
||||
expect(ru.securityAudit.verdict.SAFE).toBe('Угроз высокого риска не обнаружено')
|
||||
|
|
@ -27,5 +29,6 @@ describe('security audit locales', () => {
|
|||
expect(Object.keys(en.securityAudit.verdict).sort()).toEqual(
|
||||
Object.keys(ru.securityAudit.verdict).sort(),
|
||||
)
|
||||
expect(Object.keys(ko.securityAudit.verdict).sort()).toEqual(Object.keys(en.securityAudit.verdict).sort())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import en from './locales/en.json'
|
||||
import ko from './locales/ko.json'
|
||||
import ru from './locales/ru.json'
|
||||
import zh from './locales/zh.json'
|
||||
|
||||
|
|
@ -11,5 +12,7 @@ describe('suite locales', () => {
|
|||
expect(en.nav.suites).toBeTruthy()
|
||||
expect(ru.nav.suites).toBeTruthy()
|
||||
expect(zh.nav.suites).toBeTruthy()
|
||||
expect(Object.keys(ko.suite).sort()).toEqual(expected)
|
||||
expect(ko.nav.suites).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -167,6 +167,12 @@
|
|||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Keep Korean words together while allowing long identifiers to wrap. */
|
||||
html:lang(ko) body {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
/** @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createInstance } from 'i18next'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import ko from '@/i18n/locales/ko.json'
|
||||
import { afterEach } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const authMethodsFixture = vi.hoisted(() => ({
|
||||
localize: false,
|
||||
reason: undefined as string | undefined,
|
||||
methods: [] as Array<{ id: string, methodType: string }>,
|
||||
bootstrapEnabled: false,
|
||||
directEnabled: false,
|
||||
|
|
@ -15,10 +20,12 @@ const authMethodsFixture = vi.hoisted(() => ({
|
|||
registrationAvailable: true,
|
||||
}))
|
||||
|
||||
const koreanI18n = createInstance()
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children }: { children: unknown }) => children,
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
useNavigate: () => authMethodsFixture.navigate,
|
||||
useSearch: () => ({ returnTo: authMethodsFixture.returnTo }),
|
||||
useSearch: () => ({ returnTo: authMethodsFixture.returnTo, reason: authMethodsFixture.reason }),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
|
|
@ -26,7 +33,7 @@ vi.mock('react-i18next', async () => {
|
|||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: (key: string) => authMethodsFixture.localize ? koreanI18n.t(key) : key,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
}),
|
||||
}
|
||||
|
|
@ -95,6 +102,8 @@ import { LoginPage } from './login'
|
|||
describe('LoginPage', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
authMethodsFixture.localize = false
|
||||
authMethodsFixture.reason = undefined
|
||||
authMethodsFixture.methods = []
|
||||
authMethodsFixture.bootstrapEnabled = false
|
||||
authMethodsFixture.directEnabled = false
|
||||
|
|
@ -120,6 +129,25 @@ describe('LoginPage', () => {
|
|||
expect(html).toContain('login.register')
|
||||
})
|
||||
|
||||
it('renders a complete Korean agreement with working policy links', async () => {
|
||||
await koreanI18n.init({ lng: 'ko', resources: { ko: { translation: ko } } })
|
||||
authMethodsFixture.localize = true
|
||||
render(<I18nextProvider i18n={koreanI18n}><LoginPage /></I18nextProvider>)
|
||||
const terms = screen.getByRole('link', { name: '이용약관' })
|
||||
const privacy = screen.getByRole('link', { name: '개인정보처리방침' })
|
||||
expect(terms.getAttribute('href')).toBe('/terms')
|
||||
expect(privacy.getAttribute('href')).toBe('/privacy')
|
||||
expect(terms.parentElement?.textContent).toBe('로그인하면 이용약관 및 개인정보처리방침에 동의하게 됩니다.')
|
||||
})
|
||||
|
||||
it('shows a translated disabled-account reason instead of a missing key', async () => {
|
||||
await koreanI18n.init({ lng: 'ko', resources: { ko: { translation: ko } } })
|
||||
authMethodsFixture.localize = true
|
||||
authMethodsFixture.reason = 'accountDisabled'
|
||||
render(<I18nextProvider i18n={koreanI18n}><LoginPage /></I18nextProvider>)
|
||||
expect(screen.getByText('비활성화된 계정입니다')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides password and registration entry when local authentication is disabled', () => {
|
||||
authMethodsFixture.passwordEnabled = false
|
||||
authMethodsFixture.registrationAvailable = false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link, useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { ArrowRight, Eye, EyeOff, LockKeyhole, UserRound } from 'lucide-react'
|
||||
import { getSessionBootstrapRuntimeConfig } from '@/api/client'
|
||||
import { AuthShell } from '@/features/auth/auth-shell'
|
||||
|
|
@ -20,7 +20,7 @@ import { resolveAuthReturnTo } from '@/shared/lib/auth-route'
|
|||
* preserving the route the user originally intended to visit.
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const search = useSearch({ from: '/login' })
|
||||
const loginMutation = usePasswordLogin()
|
||||
|
|
@ -30,13 +30,12 @@ export function LoginPage() {
|
|||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loginMode, setLoginMode] = useState<'personal' | 'organization'>('personal')
|
||||
const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({})
|
||||
const isChinese = i18n.resolvedLanguage?.split('-')[0] === 'zh'
|
||||
const returnTo = resolveAuthReturnTo(search.returnTo)
|
||||
const { data: authMethods, isLoading: authMethodsLoading } = useAuthMethods(returnTo)
|
||||
const { data: localCapabilities, isError: localCapabilitiesError } = useLocalAuthCapabilities()
|
||||
const passwordEnabled = localCapabilities?.passwordLoginEnabled === true
|
||||
const effectiveLoginMode = passwordEnabled ? loginMode : 'organization'
|
||||
const disabledMessage = search.reason === 'accountDisabled' ? t('apiError.auth.accountDisabled') : null
|
||||
const disabledMessage = search.reason === 'accountDisabled' ? t('error.auth.local.accountDisabled') : null
|
||||
const bootstrapMethod = authMethods?.find((method) => method.methodType === 'SESSION_BOOTSTRAP')
|
||||
const hasOrganizationMethod = bootstrapConfig.enabled
|
||||
const hasExternalMethods = authMethods?.some((method) => method.methodType === 'OAUTH_REDIRECT')
|
||||
|
|
@ -203,17 +202,14 @@ export function LoginPage() {
|
|||
</p> : null}
|
||||
|
||||
<p className="mt-auto text-center text-xs text-muted-foreground">
|
||||
{t('login.agreementPrefix')}
|
||||
{isChinese ? null : ' '}
|
||||
<Link to="/terms" className="text-primary hover:underline">
|
||||
{t('login.terms')}
|
||||
</Link>
|
||||
{isChinese ? null : ' '}
|
||||
{t('login.and')}
|
||||
{isChinese ? null : ' '}
|
||||
<Link to="/privacy" className="text-primary hover:underline">
|
||||
{t('login.privacy')}
|
||||
</Link>
|
||||
<Trans
|
||||
t={t}
|
||||
i18nKey="login.agreement"
|
||||
components={{
|
||||
terms: <Link to="/terms" className="text-primary hover:underline" />,
|
||||
privacy: <Link to="/privacy" className="text-primary hover:underline" />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
|
|
|||
|
|
@ -1,33 +1,43 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockI18n = vi.hoisted(() => ({ resolvedLanguage: 'en' }))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
i18n: mockI18n,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/components/legal-document', () => ({
|
||||
LegalDocument: (props: { title: string; summary: string }) => (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.summary}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { PrivacyPolicyPage } from './privacy'
|
||||
|
||||
describe('PrivacyPolicyPage', () => {
|
||||
it('renders the english privacy policy for non-chinese locales', () => {
|
||||
it('renders the english privacy policy for the English locale', () => {
|
||||
mockI18n.resolvedLanguage = 'en'
|
||||
const html = renderToStaticMarkup(<PrivacyPolicyPage />)
|
||||
|
||||
expect(html).toContain('Privacy Policy')
|
||||
expect(html).toContain('This policy explains')
|
||||
})
|
||||
|
||||
it.each(['ko', 'ko-KR'])('renders the complete Korean policy for %s', (language) => {
|
||||
mockI18n.resolvedLanguage = language
|
||||
const html = renderToStaticMarkup(<PrivacyPolicyPage />)
|
||||
expect(html).toContain('개인정보처리방침')
|
||||
expect(html).toContain('8. 방침 업데이트 및 문의')
|
||||
expect(html).toContain('2026년 3월 14일')
|
||||
mockI18n.resolvedLanguage = 'en'
|
||||
const english = renderToStaticMarkup(<PrivacyPolicyPage />)
|
||||
expect((html.match(/<li>/g) ?? []).length).toBe((english.match(/<li>/g) ?? []).length)
|
||||
})
|
||||
|
||||
it('preserves the English fallback for other languages', () => {
|
||||
mockI18n.resolvedLanguage = 'ru'
|
||||
expect(renderToStaticMarkup(<PrivacyPolicyPage />)).toContain('Privacy Policy')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import koLegal from '@/i18n/locales/ko-legal.json'
|
||||
import { LegalDocument } from '@/shared/components/legal-document'
|
||||
|
||||
const privacyDocuments = {
|
||||
ko: koLegal.privacy,
|
||||
zh: {
|
||||
eyebrow: '法律与隐私',
|
||||
title: '隐私政策',
|
||||
|
|
@ -152,7 +154,8 @@ const privacyDocuments = {
|
|||
|
||||
export function PrivacyPolicyPage() {
|
||||
const { i18n } = useTranslation()
|
||||
const language = i18n.resolvedLanguage?.split('-')[0] === 'zh' ? 'zh' : 'en'
|
||||
const primaryLanguage = i18n.resolvedLanguage?.split('-')[0]
|
||||
const language = primaryLanguage === 'zh' || primaryLanguage === 'ko' ? primaryLanguage : 'en'
|
||||
|
||||
return <LegalDocument {...privacyDocuments[language]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,43 @@
|
|||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockI18n = vi.hoisted(() => ({ resolvedLanguage: 'en' }))
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { resolvedLanguage: 'en' },
|
||||
i18n: mockI18n,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/shared/components/legal-document', () => ({
|
||||
LegalDocument: (props: { title: string; summary: string }) => (
|
||||
<div>
|
||||
<h1>{props.title}</h1>
|
||||
<p>{props.summary}</p>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
import { TermsOfServicePage } from './terms'
|
||||
|
||||
describe('TermsOfServicePage', () => {
|
||||
it('renders the english terms of service for non-chinese locales', () => {
|
||||
it('renders the english terms of service for the English locale', () => {
|
||||
mockI18n.resolvedLanguage = 'en'
|
||||
const html = renderToStaticMarkup(<TermsOfServicePage />)
|
||||
|
||||
expect(html).toContain('Terms of Service')
|
||||
expect(html).toContain('These terms apply')
|
||||
})
|
||||
|
||||
it.each(['ko', 'ko-KR'])('renders the complete Korean policy for %s', (language) => {
|
||||
mockI18n.resolvedLanguage = language
|
||||
const html = renderToStaticMarkup(<TermsOfServicePage />)
|
||||
expect(html).toContain('이용약관')
|
||||
expect(html).toContain('9. 종료, 변경 및 문의')
|
||||
expect(html).toContain('2026년 3월 14일')
|
||||
mockI18n.resolvedLanguage = 'en'
|
||||
const english = renderToStaticMarkup(<TermsOfServicePage />)
|
||||
expect((html.match(/<li>/g) ?? []).length).toBe((english.match(/<li>/g) ?? []).length)
|
||||
})
|
||||
|
||||
it('preserves the English fallback for other languages', () => {
|
||||
mockI18n.resolvedLanguage = 'ru'
|
||||
expect(renderToStaticMarkup(<TermsOfServicePage />)).toContain('Terms of Service')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import koLegal from '@/i18n/locales/ko-legal.json'
|
||||
import { LegalDocument } from '@/shared/components/legal-document'
|
||||
|
||||
const termsDocuments = {
|
||||
ko: koLegal.terms,
|
||||
zh: {
|
||||
eyebrow: '法律与政策',
|
||||
title: '服务条款',
|
||||
|
|
@ -168,7 +170,8 @@ const termsDocuments = {
|
|||
|
||||
export function TermsOfServicePage() {
|
||||
const { i18n } = useTranslation()
|
||||
const language = i18n.resolvedLanguage?.split('-')[0] === 'zh' ? 'zh' : 'en'
|
||||
const primaryLanguage = i18n.resolvedLanguage?.split('-')[0]
|
||||
const language = primaryLanguage === 'zh' || primaryLanguage === 'ko' ? primaryLanguage : 'en'
|
||||
|
||||
return <LegalDocument {...termsDocuments[language]} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,23 @@ describe('LanguageSwitcher', () => {
|
|||
expect(screen.getByRole('button', { name: /English/i })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('selects Korean and closes the menu', () => {
|
||||
mockI18n.language = 'en'
|
||||
mockI18n.resolvedLanguage = 'en'
|
||||
render(<LanguageSwitcher />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /English/i }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '한국어' }))
|
||||
expect(mockI18n.changeLanguage).toHaveBeenCalledWith('ko')
|
||||
expect(screen.getByRole('button', { name: /한국어/ })).toBeTruthy()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Korean for the regional locale ko-KR', () => {
|
||||
mockI18n.language = 'ko-KR'
|
||||
render(<LanguageSwitcher />)
|
||||
expect(screen.getByRole('button', { name: /한국어/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('updates the displayed language after selecting English', () => {
|
||||
mockI18n.language = 'zh'
|
||||
mockI18n.resolvedLanguage = 'zh'
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
|
|||
const languages = [
|
||||
{ code: 'zh', name: '中文' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'ko', name: '한국어' },
|
||||
{ code: 'ru', name: 'Русский' },
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,21 @@ describe('handleApiError', () => {
|
|||
expect(window.location.href).toBe('/login?reason=accountDisabled')
|
||||
})
|
||||
|
||||
it('recognizes a Korean disabled-account message after switching to English', async () => {
|
||||
const { ApiError, handleApiError } = await import('./api-error')
|
||||
await i18n.changeLanguage('en')
|
||||
handleApiError(new ApiError('비활성화된 계정입니다', 401, '비활성화된 계정입니다'))
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
expect(window.location.href).toBe('/login?reason=accountDisabled')
|
||||
})
|
||||
|
||||
it('shows localized network errors in Korean', async () => {
|
||||
const { ApiError, handleApiError } = await import('./api-error')
|
||||
await i18n.changeLanguage('ko')
|
||||
handleApiError(new ApiError('Network error', 0))
|
||||
expect(errorSpy).toHaveBeenLastCalledWith('네트워크 연결에 실패했습니다. 네트워크를 확인하세요')
|
||||
})
|
||||
|
||||
it('falls back to the server message for non-standard api errors', async () => {
|
||||
const { ApiError, handleApiError } = await import('./api-error')
|
||||
|
||||
|
|
|
|||
|
|
@ -28,10 +28,11 @@ export class ApiError extends Error {
|
|||
|
||||
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'),
|
||||
i18n.t('error.auth.local.accountDisabled'),
|
||||
i18n.getFixedT('en')('error.auth.local.accountDisabled'),
|
||||
i18n.getFixedT('ko')('error.auth.local.accountDisabled'),
|
||||
i18n.getFixedT('ru')('error.auth.local.accountDisabled'),
|
||||
i18n.getFixedT('zh')('error.auth.local.accountDisabled'),
|
||||
]
|
||||
const normalizedServerMessage = (error.serverMessage ?? '').toLowerCase()
|
||||
const normalizedMessage = error.message.toLowerCase()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@ describe('syncDocumentLanguage', () => {
|
|||
expect(document.getElementById('root')?.getAttribute('translate')).toBe('no')
|
||||
})
|
||||
|
||||
it('updates the document language when switching from Korean back to English', () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
syncDocumentLanguage('ko-KR')
|
||||
expect(document.documentElement.lang).toBe('ko')
|
||||
syncDocumentLanguage('en')
|
||||
expect(document.documentElement.lang).toBe('en')
|
||||
expect(document.getElementById('root')?.getAttribute('translate')).toBe('no')
|
||||
})
|
||||
|
||||
it('uses the primary subtag for other locales', () => {
|
||||
document.body.innerHTML = '<div id="root"></div>'
|
||||
syncDocumentLanguage('ru-RU')
|
||||
|
|
|
|||
35
web/src/shared/lib/format-relative-time.test.ts
Normal file
35
web/src/shared/lib/format-relative-time.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from '@/i18n/config'
|
||||
import { formatRelativeTime } from './format-relative-time'
|
||||
|
||||
const now = new Date('2026-06-15T12:00:00Z')
|
||||
const ago = (minutes: number) => new Date(now.getTime() - minutes * 60_000).toISOString()
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it.each([
|
||||
[0, '방금'], [0.5, '방금'], [1, '1분 전'], [59, '59분 전'],
|
||||
[60, '1시간 전'], [1439, '23시간 전'], [1440, '1일 전'], [29 * 1440, '29일 전'],
|
||||
])('formats %s minutes ago in Korean', (minutes, expected) => {
|
||||
expect(formatRelativeTime(ago(minutes), 'ko-KR')).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([['en', '5m'], ['zh-CN', '5分钟'], ['ru-RU', '5 мин']])('preserves compact %s output', (locale, expected) => {
|
||||
expect(formatRelativeTime(ago(5), locale)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses the selected locale for older dates', () => {
|
||||
const date = ago(30 * 1440)
|
||||
expect(formatRelativeTime(date, 'ko')).toBe(new Date(date).toLocaleDateString('ko'))
|
||||
})
|
||||
|
||||
it.each([0, 5, 60, 1440, 30 * 1440])('does not append a second suffix in Korean notifications (%s minutes)', (minutes) => {
|
||||
const time = formatRelativeTime(ago(minutes), 'ko')
|
||||
expect(i18n.getFixedT('ko')('notification.timeAgo', { time })).toBe(time)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,34 +1,16 @@
|
|||
/**
|
||||
* Formats a timestamp as a compact relative time string for notification UI.
|
||||
* Mirrors the zh inline pattern; ru support added for the Russian locale.
|
||||
*/
|
||||
import i18n from '@/i18n/config'
|
||||
|
||||
/** Formats a timestamp for skill cards and notification UI in the selected language. */
|
||||
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')
|
||||
const t = i18n.getFixedT(lang)
|
||||
|
||||
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)
|
||||
if (minutes < 1) return t('relativeTime.justNow')
|
||||
if (minutes < 60) return t('relativeTime.minutes', { count: minutes })
|
||||
if (hours < 24) return t('relativeTime.hours', { count: hours })
|
||||
if (days < 30) return t('relativeTime.days', { count: days })
|
||||
return new Date(dateStr).toLocaleDateString(lang)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue