Merge pull request #1021 from Jyoung706/feat/korean-web-localization
Some checks failed
Deploy Docs / build (push) Has been cancelled
Security / Dependency Review (push) Has been cancelled
Security / CodeQL (java-kotlin) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Deploy Docs / Deploy (push) Has been cancelled

feat(i18n): add Korean language support to the web client
This commit is contained in:
XiaoSeS 2026-10-26 17:48:27 +08:00 • committed by GitHub
commit daee5efd6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 4420 additions and 169 deletions

View file

@ -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

View file

@ -64,7 +64,7 @@ Skill,欢迎分享给 SkillHub 社区,与大家一起丰富开放、实用
- **API 令牌管理** — 为 CLI 和程序化访问生成作用域令牌,采用基于前缀的安全哈希。
- **CLI 优先** — 原生 REST API,加上对现有 ClawHub 风格注册中心客户端的兼容层。原生 CLI API 是主要支持路径,协议兼容性持续扩展中。
- **可插拔存储** — 开发环境使用本地文件系统,生产环境使用 S3 / MinIO。通过配置切换。
- **国际化** — 使用 i18next 支持多语言。
- **国际化** — 使用 i18next 支持英语、简体中文、俄语和韩语。
## 快速开始

View file

@ -1,6 +1,6 @@
import { randomBytes } from 'node:crypto'
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
test.describe('Auth Entry', () => {
test.beforeEach(async ({ page }) => {
@ -234,6 +234,7 @@ test.describe('Auth Entry', () => {
test('logs in to an explicit local destination on the isolated runtime', async ({ browser, page }) => {
test.skip(process.env.SKILLHUB_RUNTIME_E2E !== '1', 'Requires an isolated runtime with local registration enabled')
test.setTimeout(90_000)
await setUniqueClientIp(page, 'auth-entry-runtime-registration')
const suffix = randomBytes(6).toString('hex')
const username = `pr901_${suffix}`

View file

@ -1,4 +1,18 @@
import type { Page } from '@playwright/test'
import { randomBytes } from 'node:crypto'
import type { BrowserContext, Page } from '@playwright/test'
const clientIpByContext = new WeakMap<BrowserContext, string>()
/** Stable identity for fixture setup, without replacing the context's other headers. */
export function fixtureClientIp(page: Page): string {
const context = page.context()
let address = clientIpByContext.get(context)
if (!address) {
address = `10.${[...randomBytes(3)].join('.')}`
clientIpByContext.set(context, address)
}
return address
}
export async function setEnglishLocale(page: Page) {
await page.addInitScript(() => {
@ -10,7 +24,9 @@ export async function setUniqueClientIp(page: Page, seed: string) {
const suffix = Date.now() + Math.floor(Math.random() * 1000)
const thirdOctet = seed.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 250
const fourthOctet = suffix % 250
const address = `10.0.${thirdOctet}.${fourthOctet}`
clientIpByContext.set(page.context(), address)
await page.context().setExtraHTTPHeaders({
'X-Forwarded-For': `10.0.${thirdOctet}.${fourthOctet}`,
'X-Forwarded-For': address,
})
}

View file

@ -4,17 +4,20 @@ const csrfCookieName = 'XSRF-TOKEN'
const csrfHeaderName = 'X-XSRF-TOKEN'
const requestTimeoutMs = process.env.CI ? 12_000 : 8_000
async function readCsrfToken(page: Page): Promise<string | null> {
const cookie = (await page.context().cookies()).find((item) => item.name === csrfCookieName)
async function readCsrfToken(page: Page, requestUrl: string): Promise<string | null> {
const cookie = (await page.context().cookies(requestUrl)).find((item) => item.name === csrfCookieName)
return cookie?.value?.trim() || null
}
export async function csrfHeaders(page: Page, headers?: Record<string, string>): Promise<Record<string, string>> {
let token = await readCsrfToken(page)
if (!token) {
await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs })
token = await readCsrfToken(page)
// Resolve the API context's actual origin, including before page.goto(). Cached
// sessions can contain both localhost and 127.0.0.1 cookies; only use cookies
// that the browser would send to this server, and refresh after session changes.
const response = await page.context().request.get('/api/v1/auth/providers', { timeout: requestTimeoutMs })
if (!response.ok()) {
throw new Error(`Failed to prime CSRF cookie: HTTP ${response.status()}`)
}
const token = await readCsrfToken(page, response.url())
if (!token) {
throw new Error('Missing XSRF-TOKEN cookie after auth provider warm-up')

View file

@ -1,6 +1,7 @@
import { expect, type Page, type TestInfo } from '@playwright/test'
import { randomUUID } from 'node:crypto'
import { csrfHeaders } from './csrf'
import { fixtureClientIp } from './auth-fixtures'
const password = 'Passw0rd!123'
const cachedUserByWorker = new Map<number, string>()
@ -220,7 +221,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
password,
email: `${username}@example.test`,
},
headers: await csrfHeaders(page),
headers: await csrfHeaders(page, { 'X-Forwarded-For': fixtureClientIp(page) }),
timeout: requestTimeoutMs,
})
@ -251,7 +252,7 @@ async function registerSessionOnce(page: Page, testInfo?: TestInfo, options?: Re
password,
email: `${uniqueUsername}@example.test`,
},
headers: await csrfHeaders(page),
headers: await csrfHeaders(page, { 'X-Forwarded-For': fixtureClientIp(page) }),
timeout: requestTimeoutMs,
})
@ -311,7 +312,7 @@ async function createFreshSessionOnce(page: Page, testInfo?: TestInfo) {
password,
email: `${uniqueUsername}@example.test`,
},
headers: await csrfHeaders(page),
headers: await csrfHeaders(page, { 'X-Forwarded-For': fixtureClientIp(page) }),
timeout: requestTimeoutMs,
})

View file

@ -1,5 +1,5 @@
import { expect, test, type Page } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
import {
getSearchCard,
getSearchCards,
@ -94,6 +94,11 @@ let basicSeed: PreparedSearchSeed | undefined
test.setTimeout(300_000)
// Independent browser scenarios must not exhaust one anonymous search quota.
test.beforeEach(async ({ page }, testInfo) => {
await setUniqueClientIp(page, testInfo.testId)
})
test.beforeAll(async ({ browser }, testInfo) => {
test.setTimeout(300_000)
basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 })
@ -331,7 +336,6 @@ test.describe('Search Card Loading State (Real API)', () => {
// ─── Responsive Layout ────────────────────────────────────────────────────────
test.describe('Search Card Responsive Layout (Real API)', () => {
test.describe.configure({ retries: 2 })
test.use({ hasTouch: true })
test.beforeEach(async ({ page }) => {

View file

@ -1,5 +1,5 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures'
import {
DEFAULT_SEARCH_KEYWORD,
getSearchCards,
@ -16,6 +16,11 @@ let basicSeed: PreparedSearchSeed | undefined
test.setTimeout(300_000)
// Independent browser scenarios must not exhaust one anonymous search quota.
test.beforeEach(async ({ page }, testInfo) => {
await setUniqueClientIp(page, testInfo.testId)
})
test.beforeAll(async ({ browser }, testInfo) => {
test.setTimeout(300_000)
basicSeed = await prepareSearchSeed(browser, testInfo, { count: 13 })

View file

@ -0,0 +1,71 @@
import { randomUUID } from 'node:crypto'
import { expect, test } from '@playwright/test'
import { csrfHeaders } from './helpers/csrf'
import { fixtureClientIp } from './helpers/auth-fixtures'
import { createFreshSession } from './helpers/session'
test.describe('E2E fixture session isolation (Real API)', () => {
test('selects the CSRF cookie for the request origin, not another loopback host', async ({ page, baseURL }) => {
const primary = new URL(baseURL!)
test.skip(!['localhost', '127.0.0.1'].includes(primary.hostname), 'Requires the same local server on both loopback names')
const alternate = new URL(primary)
alternate.hostname = primary.hostname === 'localhost' ? '127.0.0.1' : 'localhost'
const providers = '/api/v1/auth/providers'
expect((await page.request.get(new URL(providers, alternate).href)).ok()).toBeTruthy()
expect((await page.request.get(providers)).ok()).toBeTruthy()
const cookies = await page.context().cookies()
expect(new Set(cookies.filter(c => c.name === 'XSRF-TOKEN').map(c => c.domain)).size).toBe(2)
const response = await page.request.post('/api/v1/auth/local/login', {
data: { username: `missing_${randomUUID().slice(0, 8)}`, password: 'InvalidPassword!123' },
headers: await csrfHeaders(page),
})
// A valid CSRF token reaches authentication and rejects the nonexistent account.
// A foreign-origin token fails earlier with 403.
expect(response.status()).toBe(401)
const invalidCsrf = await page.request.post('/api/v1/auth/local/login', {
data: { username: 'missing_account', password: 'InvalidPassword!123' },
headers: { 'X-XSRF-TOKEN': 'deliberately-invalid-csrf' },
})
expect(invalidCsrf.status()).toBe(403)
})
test('fresh-account fixtures do not share one anonymous registration quota', async ({ browser, baseURL }, testInfo) => {
test.setTimeout(30_000)
const usernames = new Set<string>()
// The server's default anonymous registration quota is five per client in five minutes.
// Seven independent contexts must remain seven independent fixture clients.
for (let index = 0; index < 7; index += 1) {
const context = await browser.newContext({ baseURL })
try {
const page = await context.newPage()
const account = await createFreshSession(page, testInfo)
usernames.add(account.username)
expect((await page.request.get('/api/v1/auth/me')).status()).toBe(200)
} finally {
await context.close()
}
}
expect(usernames.size).toBe(7)
})
test('the server still throttles repeated registration attempts from one client', async ({ browser, baseURL }) => {
let address: string | undefined
for (let index = 0; index < 6; index += 1) {
const context = await browser.newContext({ baseURL })
try {
const page = await context.newPage()
address ??= fixtureClientIp(page)
const response = await page.request.post('/api/v1/auth/local/register', {
// Invalid input consumes the anonymous quota without creating accounts.
data: { username: 'x', password: 'invalid', email: 'invalid' },
headers: await csrfHeaders(page, { 'X-Forwarded-For': address }),
})
expect(response.status()).toBe(index < 5 ? 400 : 429)
} finally {
await context.close()
}
}
})
})

View file

@ -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>

View file

@ -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>
)
}

View file

@ -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',

View file

@ -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 })
: '',
}
}

View file

@ -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)
})

View file

@ -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'

View file

@ -7,7 +7,8 @@ import { SkillCard } from './skill-card'
vi.mock('@/api/client', () => ({ buildApiUrl: (url: string) => url }))
vi.mock('@/features/auth/use-auth', () => ({ useAuth: () => ({ isAuthenticated: false }) }))
vi.mock('@/features/social/use-star', () => ({ useStarredIdSet: () => ({ starredIds: new Set<number>() }) }))
vi.mock('react-i18next', () => ({
vi.mock('react-i18next', async (importOriginal) => ({
...await importOriginal<typeof import('react-i18next')>(),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: 'en' } }),
}))

View file

@ -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' },

View file

@ -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()

View file

@ -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')

View file

@ -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 },
},

View file

@ -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()

View file

@ -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()
}
})
})

View 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')
}
})
})

View file

@ -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}}')

View 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')
})
})

View file

@ -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"
@ -2210,6 +2212,9 @@
}
},
"footer": {
"license": "License",
"contributing": "Contributing",
"changelog": "Changelog",
"product": "Product",
"marketplace": "Skill marketplace",
"publish": "Publish skill",
@ -3242,5 +3247,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."
}
}
}

View 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 인스턴스에서 제공하는 문서, 커뮤니티, 지원 채널 또는 관리자 연락처를 이용하세요."
]
}
]
}
}

3584
web/src/i18n/locales/ko.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -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": "Опубликовать скилл",
@ -3244,5 +3249,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}} отозвана."
}
}
}

View file

@ -6,6 +6,7 @@
"evidence": "证据"
},
"common": {
"loading": "加载中...",
"expand": "展开详情",
"collapse": "收起详情"
},
@ -379,6 +380,7 @@
"enterpriseContinue": "通过企业账号登录",
"switchToPersonal": "改用个人账号",
"agreementPrefix": "登录即表示你同意我们的",
"agreement": "登录即表示你同意我们的<terms>服务条款</terms>和<privacy>隐私政策</privacy>",
"terms": "服务条款",
"and": "和",
"privacy": "隐私政策"
@ -2209,6 +2211,9 @@
}
},
"footer": {
"license": "许可证",
"contributing": "参与贡献",
"changelog": "更新日志",
"product": "产品",
"marketplace": "技能市场",
"publish": "发布技能",
@ -3241,5 +3246,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}} 版本已撤回。"
}
}
}

View file

@ -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())
})
})

View file

@ -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()
})
})

View file

@ -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;

View file

@ -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

View file

@ -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>

View file

@ -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')
})
})

View file

@ -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]} />
}

View file

@ -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')
})
})

View file

@ -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]} />
}

View file

@ -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'

View file

@ -24,6 +24,7 @@ export function LanguageSwitcher({ className }: LanguageSwitcherProps) {
const languages = [
{ code: 'zh', name: '中文' },
{ code: 'en', name: 'English' },
{ code: 'ko', name: '한국어' },
{ code: 'ru', name: 'Русский' },
]

View file

@ -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')

View file

@ -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()

View file

@ -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')

View 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)
})
})

View file

@ -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)
}