) => Object.entries(values ?? {}).reduce(
+ (text, [name, value]) => text.replace(`{{${name}}}`, String(value)),
+ translations[key] ?? key,
+ ),
+ }),
+ }
+})
+
+vi.mock('@/features/auth/use-auth', () => ({
+ useAuth: () => ({ isAuthenticated: true, hasRole: () => false }),
+}))
+
+vi.mock('@/shared/lib/date-time', () => ({
+ formatLocalDateTime: (value: string) => value,
+}))
+
+vi.mock('@/shared/lib/toast', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+vi.mock('./use-skill-reviews', () => ({
+ useSkillReviews: (_skillId: number, page: number) => {
+ mocks.requestedPages.push(page)
+ return { data: mocks.pages.get(page), isLoading: false, isError: false }
+ },
+ useMySkillReview: (_skillId: number, enabled: boolean) => {
+ mocks.mineEnabled.push(enabled)
+ return { data: {
+ rated: true,
+ score: 4,
+ reviewed: true,
+ reviewId: 7,
+ reviewText: 'Useful review',
+ status: 'VISIBLE',
+ updatedAt: '2026-09-01T00:00:00Z',
+ } }
+ },
+ useUpsertSkillReview: () => ({ mutate: mocks.saveMutate, isPending: false }),
+ useClearSkillReview: () => ({ mutate: mocks.clearMutate, isPending: false }),
+ useModerateSkillReview: () => ({ mutate: mocks.moderateMutate, isPending: false }),
+}))
+
+import { SkillReviews } from './skill-reviews'
+
+describe('skill reviews', () => {
+ beforeEach(() => {
+ mocks.pages.clear()
+ mocks.pages.set(0, { items: [], total: 0, page: 0, size: 20 })
+ mocks.requestedPages.length = 0
+ mocks.mineEnabled.length = 0
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+ })
+
+ it('exposes an accessible rating group and labelled review editor', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Edit my review' }))
+
+ const rating = screen.getByRole('radiogroup', { name: 'Your score' })
+ expect(rating).toBeTruthy()
+ expect(screen.getByRole('radio', { name: 'Rate 4 out of 5 stars' }))
+ .toHaveProperty('checked', true)
+ expect(screen.getByRole('textbox', { name: 'Review text' })).toHaveProperty('value', 'Useful review')
+ })
+
+ it('keeps a way back when a refetch empties the last page', () => {
+ mocks.pages.set(0, { items: [], total: 21, page: 0, size: 20 })
+ mocks.pages.set(1, {
+ items: [{
+ id: 8,
+ displayName: 'Alice',
+ score: 5,
+ reviewText: 'Great',
+ status: 'VISIBLE',
+ authoredByViewer: false,
+ createdAt: '2026-09-01T00:00:00Z',
+ updatedAt: '2026-09-01T00:00:00Z',
+ }],
+ total: 21,
+ page: 1,
+ size: 20,
+ })
+ const view = render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next' }))
+ expect(mocks.requestedPages).toContain(1)
+
+ mocks.pages.set(1, { items: [], total: 20, page: 1, size: 20 })
+ view.rerender()
+ fireEvent.click(screen.getByRole('button', { name: 'Previous' }))
+ expect(mocks.requestedPages[mocks.requestedPages.length - 1]).toBe(0)
+ })
+
+ it('wraps long reviewer names and review text', () => {
+ const reviewer = 'review_author_1788284593_353294'
+ const reviewText = 'x'.repeat(200)
+ mocks.pages.set(0, {
+ items: [{
+ id: 8,
+ displayName: reviewer,
+ score: 5,
+ reviewText,
+ status: 'VISIBLE',
+ authoredByViewer: false,
+ createdAt: '2026-09-01T00:00:00Z',
+ updatedAt: '2026-09-01T00:00:00Z',
+ }],
+ total: 1,
+ page: 0,
+ size: 20,
+ })
+
+ render()
+
+ expect(screen.getByText(reviewer).className).toContain('[overflow-wrap:anywhere]')
+ expect(screen.getByText(reviewText).className).toContain('[overflow-wrap:anywhere]')
+ })
+
+ it('keeps review interaction copy in every supported locale', () => {
+ for (const locale of [en, zh, ru]) {
+ expect(locale.skillReviews.ratingDisplay).toBeTruthy()
+ expect(locale.skillReviews.ratingOption).toBeTruthy()
+ expect(locale.skillReviews.reviewTextLabel).toBeTruthy()
+ expect(locale.skillReviews.hide).toBeTruthy()
+ expect(locale.skillReviews.restore).toBeTruthy()
+ }
+ expect(en.skillReviews.count_one).toBe('{{count}} review')
+ expect(en.skillReviews.count_other).toBe('{{count}} reviews')
+ expect(ru.skillReviews.count_one).toBeTruthy()
+ expect(ru.skillReviews.count_few).toBeTruthy()
+ expect(ru.skillReviews.count_many).toBeTruthy()
+ expect(ru.skillReviews.count_other).toBeTruthy()
+ })
+
+ it('lets an authenticated author clear existing text when the skill is not interactable', () => {
+ render()
+
+ expect(mocks.mineEnabled).toContain(true)
+ fireEvent.click(screen.getByRole('button', { name: 'Delete review' }))
+ expect(mocks.clearMutate).toHaveBeenCalledOnce()
+ expect(screen.queryByRole('button', { name: 'Edit my review' })).toBeNull()
+ })
+})
diff --git a/web/src/features/social/skill-reviews.tsx b/web/src/features/social/skill-reviews.tsx
new file mode 100644
index 00000000..0e627e49
--- /dev/null
+++ b/web/src/features/social/skill-reviews.tsx
@@ -0,0 +1,329 @@
+import { useId, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Loader2, MessageSquare, ShieldAlert, Star } from 'lucide-react'
+import { useAuth } from '@/features/auth/use-auth'
+import { Button } from '@/shared/ui/button'
+import { Card } from '@/shared/ui/card'
+import { Textarea } from '@/shared/ui/textarea'
+import { formatLocalDateTime } from '@/shared/lib/date-time'
+import { toast } from '@/shared/lib/toast'
+import { cn } from '@/shared/lib/utils'
+import {
+ type MySkillReview,
+ type SkillReview,
+ useClearSkillReview,
+ useModerateSkillReview,
+ useMySkillReview,
+ useSkillReviews,
+ useUpsertSkillReview,
+} from './use-skill-reviews'
+
+interface SkillReviewsProps {
+ skillId: number
+ canInteract: boolean
+ onRequireLogin: () => void
+}
+
+function ReviewStars({ value, onChange, disabled = false }: {
+ value: number
+ onChange?: (value: number) => void
+ disabled?: boolean
+}) {
+ const { t } = useTranslation()
+ const ratingName = useId()
+
+ if (!onChange) {
+ return (
+
+ {[1, 2, 3, 4, 5].map((score) => (
+
+ ))}
+
+ )
+ }
+
+ return (
+
+ {[1, 2, 3, 4, 5].map((score) => (
+
+ onChange(score)}
+ disabled={disabled}
+ aria-label={t('skillReviews.ratingOption', { score })}
+ />
+
+
+ ))}
+
+ )
+}
+
+function ReviewEditor({ skillId, review, onDone }: {
+ skillId: number
+ review?: MySkillReview
+ onDone: () => void
+}) {
+ const { t } = useTranslation()
+ const reviewTextId = useId()
+ const [score, setScore] = useState(review?.rated ? review.score : 5)
+ const [reviewText, setReviewText] = useState(review?.reviewText ?? '')
+ const save = useUpsertSkillReview(skillId)
+ const clear = useClearSkillReview(skillId)
+ const pending = save.isPending || clear.isPending
+
+ const handleSave = () => {
+ const normalized = reviewText.trim()
+ if (!normalized) {
+ toast.error(t('skillReviews.textRequired'))
+ return
+ }
+ save.mutate({ score, reviewText: normalized }, {
+ onSuccess: () => {
+ toast.success(t('skillReviews.saved'))
+ onDone()
+ },
+ onError: (error) => toast.error(t('skillReviews.saveFailed'), error.message),
+ })
+ }
+
+ const handleDelete = () => {
+ clear.mutate(undefined, {
+ onSuccess: () => {
+ toast.success(t('skillReviews.deleted'))
+ onDone()
+ },
+ onError: (error) => toast.error(t('skillReviews.deleteFailed'), error.message),
+ })
+ }
+
+ return (
+
+
+ {t('skillReviews.scoreLabel')}
+
+
+
+
+ )
+}
+
+function ReviewRow({ skillId, review, canModerate }: {
+ skillId: number
+ review: SkillReview
+ canModerate: boolean
+}) {
+ const { t, i18n } = useTranslation()
+ const moderation = useModerateSkillReview(skillId)
+ const hidden = review.status === 'HIDDEN'
+ const initials = review.displayName.trim().slice(0, 1).toUpperCase() || '?'
+
+ const moderate = () => {
+ moderation.mutate({ reviewId: review.id, action: hidden ? 'restore' : 'hide' }, {
+ onSuccess: () => toast.success(t(hidden ? 'skillReviews.restored' : 'skillReviews.hidden')),
+ onError: (error) => toast.error(t('skillReviews.moderationFailed'), error.message),
+ })
+ }
+
+ return (
+
+
+ {review.avatarUrl ? (
+

+ ) : (
+
+ {initials}
+
+ )}
+
+
+
+ {review.displayName}
+
+ {hidden ? (
+
+ {t('skillReviews.hiddenStatus')}
+
+ ) : null}
+
+
+ {formatLocalDateTime(review.updatedAt, i18n.language)}
+
+
+
{review.reviewText}
+ {hidden && review.moderationReason ? (
+
+ {t('skillReviews.moderationReason', { reason: review.moderationReason })}
+
+ ) : null}
+
+
+ {canModerate ? (
+
+
+
+ ) : null}
+
+ )
+}
+
+export function SkillReviews({ skillId, canInteract, onRequireLogin }: SkillReviewsProps) {
+ const { t } = useTranslation()
+ const { isAuthenticated, hasRole } = useAuth()
+ const [page, setPage] = useState(0)
+ const [editing, setEditing] = useState(false)
+ const reviews = useSkillReviews(skillId, page)
+ const mine = useMySkillReview(skillId, isAuthenticated)
+ const clearMine = useClearSkillReview(skillId)
+ const canModerate = hasRole('SKILL_ADMIN') || hasRole('SUPER_ADMIN')
+ const totalPages = reviews.data ? Math.ceil(reviews.data.total / reviews.data.size) : 0
+
+ const finishEditing = () => {
+ setEditing(false)
+ setPage(0)
+ }
+
+ const startEditing = () => {
+ if (!isAuthenticated) {
+ onRequireLogin()
+ return
+ }
+ setEditing(true)
+ }
+
+ const deleteUnavailableReview = () => {
+ clearMine.mutate(undefined, {
+ onSuccess: () => {
+ toast.success(t('skillReviews.deleted'))
+ setPage(0)
+ },
+ onError: (error) => toast.error(t('skillReviews.deleteFailed'), error.message),
+ })
+ }
+
+ return (
+
+
+
+
+
+
{t('skillReviews.title')}
+
+
+ {t('skillReviews.count', { count: reviews.data?.total ?? 0 })}
+
+
+ {canInteract && !editing ? (
+
+ ) : !canInteract && isAuthenticated && mine.data?.reviewed ? (
+
+ ) : null}
+
+
+ {mine.data?.status === 'HIDDEN' ? (
+
+ {t('skillReviews.yourReviewHidden')}
+ {mine.data.moderationReason ? ` ${t('skillReviews.moderationReason', { reason: mine.data.moderationReason })}` : ''}
+
+ ) : null}
+
+ {editing ? (
+
+ ) : null}
+
+ {reviews.isLoading ? (
+
+
+ {t('skillReviews.loading')}
+
+ ) : reviews.isError ? (
+
+ {t('skillReviews.loadFailed')}
+
+ ) : reviews.data?.items.length ? (
+
+ {reviews.data.items.map((review) => (
+
+ ))}
+
+ ) : (
+
+ {t('skillReviews.empty')}
+
+ )}
+
+ {page > 0 || totalPages > 1 ? (
+
+
+ {page + 1}/{Math.max(totalPages, 1)}
+
+
+ ) : null}
+
+ )
+}
diff --git a/web/src/features/social/use-skill-reviews.ts b/web/src/features/social/use-skill-reviews.ts
new file mode 100644
index 00000000..5307af56
--- /dev/null
+++ b/web/src/features/social/use-skill-reviews.ts
@@ -0,0 +1,131 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { fetchJson, getCsrfHeaders, WEB_API_PREFIX } from '@/api/client'
+import type { components } from '@/api/generated/schema'
+
+type GeneratedSkillReview = components['schemas']['SkillReviewResponse']
+type GeneratedMySkillReview = components['schemas']['SkillReviewMeResponse']
+type GeneratedSkillReviewPage = components['schemas']['PageResponseSkillReviewResponse']
+type GeneratedReviewInput = components['schemas']['SkillReviewRequest']
+
+export interface SkillReview extends Omit {
+ id: number
+ userId?: string | null
+ displayName: string
+ avatarUrl?: string | null
+ score: number
+ reviewText: string
+ status: 'VISIBLE' | 'HIDDEN'
+ authoredByViewer: boolean
+ moderationReason?: string | null
+ createdAt?: string | null
+ updatedAt?: string | null
+}
+
+export interface MySkillReview extends Omit {
+ rated: boolean
+ score: number
+ reviewed: boolean
+ reviewId?: number | null
+ reviewText?: string | null
+ status?: 'VISIBLE' | 'HIDDEN' | null
+ moderationReason?: string | null
+ createdAt?: string | null
+ updatedAt?: string | null
+}
+
+interface SkillReviewPage extends Omit {
+ items: SkillReview[]
+ total: number
+ page: number
+ size: number
+}
+
+interface ReviewInput extends Omit {
+ score: number
+ reviewText: string
+}
+
+async function listReviews(skillId: number, page: number): Promise {
+ return fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/reviews?page=${page}&size=20`)
+}
+
+async function getMyReview(skillId: number): Promise {
+ return fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/reviews/me`)
+}
+
+async function upsertReview(skillId: number, input: ReviewInput): Promise {
+ return fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/reviews/me`, {
+ method: 'PUT',
+ headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify(input),
+ })
+}
+
+async function clearReview(skillId: number): Promise {
+ return fetchJson(`${WEB_API_PREFIX}/skills/${skillId}/reviews/me`, {
+ method: 'DELETE',
+ headers: getCsrfHeaders(),
+ })
+}
+
+async function moderateReview(reviewId: number, action: 'hide' | 'restore'): Promise {
+ return fetchJson(`/api/v1/admin/skill-reviews/${reviewId}/${action}`, {
+ method: 'POST',
+ headers: getCsrfHeaders({ 'Content-Type': 'application/json' }),
+ body: action === 'hide' ? JSON.stringify({}) : undefined,
+ })
+}
+
+export function useSkillReviews(skillId: number, page: number) {
+ return useQuery({
+ queryKey: ['skills', skillId, 'reviews', page],
+ queryFn: () => listReviews(skillId, page),
+ enabled: skillId > 0,
+ })
+}
+
+export function useMySkillReview(skillId: number, enabled: boolean) {
+ return useQuery({
+ queryKey: ['skills', skillId, 'reviews', 'me'],
+ queryFn: () => getMyReview(skillId),
+ enabled: enabled && skillId > 0,
+ })
+}
+
+function useReviewMutationInvalidation(skillId: number) {
+ const queryClient = useQueryClient()
+ return () => {
+ queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'reviews'] })
+ queryClient.invalidateQueries({ queryKey: ['skills', skillId, 'rating'] })
+ queryClient.invalidateQueries({ queryKey: ['skills'] })
+ }
+}
+
+export function useUpsertSkillReview(skillId: number) {
+ const invalidate = useReviewMutationInvalidation(skillId)
+ return useMutation({
+ mutationFn: (input: ReviewInput) => upsertReview(skillId, input),
+ onSuccess: invalidate,
+ })
+}
+
+export function useClearSkillReview(skillId: number) {
+ const invalidate = useReviewMutationInvalidation(skillId)
+ return useMutation({
+ mutationFn: () => clearReview(skillId),
+ onSuccess: invalidate,
+ })
+}
+
+export function useModerateSkillReview(skillId: number) {
+ const invalidate = useReviewMutationInvalidation(skillId)
+ return useMutation({
+ mutationFn: ({ reviewId, action }: { reviewId: number; action: 'hide' | 'restore' }) =>
+ moderateReview(reviewId, action),
+ onSuccess: invalidate,
+ })
+}
diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json
index fe95fbd1..234fb599 100644
--- a/web/src/i18n/locales/en.json
+++ b/web/src/i18n/locales/en.json
@@ -1299,6 +1299,40 @@
"ratingInput": {
"yourRating": "Your rating: {{score}} stars"
},
+ "skillReviews": {
+ "title": "User reviews",
+ "count": "{{count}} reviews",
+ "count_one": "{{count}} review",
+ "count_other": "{{count}} reviews",
+ "write": "Write a review",
+ "edit": "Edit my review",
+ "scoreLabel": "Your score",
+ "ratingDisplay": "Rating: {{score}} out of 5 stars",
+ "ratingOption": "Rate {{score}} out of 5 stars",
+ "reviewTextLabel": "Review text",
+ "placeholder": "Share what worked well and what others should know.",
+ "save": "Save review",
+ "cancel": "Cancel",
+ "delete": "Delete review",
+ "saved": "Review saved",
+ "deleted": "Review deleted; your star rating was kept",
+ "textRequired": "Enter a review before saving",
+ "saveFailed": "Could not save review",
+ "deleteFailed": "Could not delete review",
+ "loadFailed": "Could not load reviews. Try again later.",
+ "empty": "No reviews yet. Be the first to share your experience.",
+ "loading": "Loading reviews...",
+ "hiddenStatus": "Hidden",
+ "yourReviewHidden": "Your review is hidden from the public list.",
+ "moderationReason": "Reason: {{reason}}",
+ "hide": "Hide review",
+ "restore": "Restore review",
+ "hidden": "Review hidden",
+ "restored": "Review restored",
+ "moderationFailed": "Could not update review visibility",
+ "previous": "Previous",
+ "next": "Next"
+ },
"createToken": {
"title": "Create API Token",
"description": "Create a new API Token for CLI or API access",
diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json
index a3d13fe4..3657874b 100644
--- a/web/src/i18n/locales/ru.json
+++ b/web/src/i18n/locales/ru.json
@@ -1511,6 +1511,42 @@
"ratingInput": {
"yourRating": "Ваша оценка: {{score}} зв."
},
+ "skillReviews": {
+ "title": "Отзывы пользователей",
+ "count": "Отзывов: {{count}}",
+ "count_one": "{{count}} отзыв",
+ "count_few": "{{count}} отзыва",
+ "count_many": "{{count}} отзывов",
+ "count_other": "{{count}} отзыва",
+ "write": "Написать отзыв",
+ "edit": "Изменить мой отзыв",
+ "scoreLabel": "Ваша оценка",
+ "ratingDisplay": "Оценка: {{score}} из 5 звёзд",
+ "ratingOption": "Поставить {{score}} из 5 звёзд",
+ "reviewTextLabel": "Текст отзыва",
+ "placeholder": "Расскажите об опыте использования и важных деталях.",
+ "save": "Сохранить отзыв",
+ "cancel": "Отмена",
+ "delete": "Удалить отзыв",
+ "saved": "Отзыв сохранён",
+ "deleted": "Отзыв удалён, оценка сохранена",
+ "textRequired": "Введите текст отзыва",
+ "saveFailed": "Не удалось сохранить отзыв",
+ "deleteFailed": "Не удалось удалить отзыв",
+ "loadFailed": "Не удалось загрузить отзывы. Повторите позже.",
+ "empty": "Отзывов пока нет. Поделитесь опытом первым.",
+ "loading": "Загрузка отзывов...",
+ "hiddenStatus": "Скрыт",
+ "yourReviewHidden": "Ваш отзыв скрыт из публичного списка.",
+ "moderationReason": "Причина: {{reason}}",
+ "hide": "Скрыть отзыв",
+ "restore": "Восстановить отзыв",
+ "hidden": "Отзыв скрыт",
+ "restored": "Отзыв восстановлен",
+ "moderationFailed": "Не удалось изменить видимость отзыва",
+ "previous": "Назад",
+ "next": "Далее"
+ },
"review": {
"detail": "Детали ревью",
"id": "ID ревью",
diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json
index 30de1e66..c8231fc9 100644
--- a/web/src/i18n/locales/zh.json
+++ b/web/src/i18n/locales/zh.json
@@ -1299,6 +1299,39 @@
"ratingInput": {
"yourRating": "你的评分: {{score}} 星"
},
+ "skillReviews": {
+ "title": "用户评价",
+ "count": "共 {{count}} 条评价",
+ "count_other": "共 {{count}} 条评价",
+ "write": "写评价",
+ "edit": "编辑我的评价",
+ "scoreLabel": "你的评分",
+ "ratingDisplay": "评分:5 星中 {{score}} 星",
+ "ratingOption": "评为 5 星中 {{score}} 星",
+ "reviewTextLabel": "评价内容",
+ "placeholder": "说说使用体验,以及其他用户需要了解的信息。",
+ "save": "保存评价",
+ "cancel": "取消",
+ "delete": "删除评价",
+ "saved": "评价已保存",
+ "deleted": "评价已删除,星级评分已保留",
+ "textRequired": "请先填写评价内容",
+ "saveFailed": "评价保存失败",
+ "deleteFailed": "评价删除失败",
+ "loadFailed": "评价加载失败,请稍后重试。",
+ "empty": "还没有评价,来分享第一条使用体验吧。",
+ "loading": "正在加载评价...",
+ "hiddenStatus": "已隐藏",
+ "yourReviewHidden": "你的评价已从公开列表隐藏。",
+ "moderationReason": "原因:{{reason}}",
+ "hide": "隐藏评价",
+ "restore": "恢复评价",
+ "hidden": "评价已隐藏",
+ "restored": "评价已恢复",
+ "moderationFailed": "评价可见性更新失败",
+ "previous": "上一页",
+ "next": "下一页"
+ },
"createToken": {
"title": "创建 API Token",
"description": "创建一个新的 API Token 用于 CLI 或 API 访问",
diff --git a/web/src/i18n/ru-locale.test.ts b/web/src/i18n/ru-locale.test.ts
index 9f262ead..83d33479 100644
--- a/web/src/i18n/ru-locale.test.ts
+++ b/web/src/i18n/ru-locale.test.ts
@@ -15,9 +15,15 @@ function placeholders(text: string): string[] {
return [...text.matchAll(/\{\{[^}]+\}\}/g)].map((match) => match[0]).sort()
}
+const pluralSuffix = /_(zero|one|two|few|many|other)$/
+
+function normalizedLeafKeys(value: unknown): string[] {
+ return [...new Set(leafKeys(value).map((key) => key.replace(pluralSuffix, '_plural')))]
+}
+
describe('russian locale', () => {
it('mirrors the english key tree', () => {
- expect(leafKeys(ru).sort()).toEqual(leafKeys(en).sort())
+ expect(normalizedLeafKeys(ru).sort()).toEqual(normalizedLeafKeys(en).sort())
})
it('preserves interpolation placeholders', () => {
@@ -36,7 +42,11 @@ describe('russian locale', () => {
for (const part of parts) {
cursor = (cursor as Record)[part]
}
- if (placeholders(String(cursor)).join() !== placeholders(enMap[key] ?? '').join()) {
+ const englishReference = enMap[key] ?? enMap[key.replace(pluralSuffix, '_other')]
+ if (englishReference === undefined) {
+ throw new Error(`missing English reference for ${key}`)
+ }
+ if (placeholders(String(cursor)).join() !== placeholders(englishReference).join()) {
mismatches.push(key)
}
}
diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx
index 4cdfc9aa..b9ba9a71 100644
--- a/web/src/pages/skill-detail.test.tsx
+++ b/web/src/pages/skill-detail.test.tsx
@@ -348,6 +348,20 @@ describe('SkillDetailPage', () => {
expect(html).not.toContain('skillDetail.deleteSkill')
})
+ it('wraps a long skill name instead of widening the mobile page', () => {
+ useSkillDetailMock.mockReturnValue({
+ data: createSkill({ displayName: 'review-runtime-1788284593-353294' }),
+ isLoading: false,
+ isFetching: false,
+ error: null,
+ })
+
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain('text-balance break-words text-4xl')
+ expect(html).toContain('[overflow-wrap:anywhere]')
+ })
+
it('shows the label management panel for a user who can manage the skill lifecycle', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({
diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx
index 7969f1fe..db43c094 100644
--- a/web/src/pages/skill-detail.tsx
+++ b/web/src/pages/skill-detail.tsx
@@ -26,6 +26,7 @@ import { isSkillDetailQueriesEnabled } from './skill-detail-query'
import { RatingInput } from '@/features/social/rating-input'
import { StarButton } from '@/features/social/star-button'
import { SubscribeButton } from '@/features/social/subscribe-button'
+import { SkillReviews } from '@/features/social/skill-reviews'
import { useAuth } from '@/features/auth/use-auth'
import { adminApi, ApiError, buildApiUrl, WEB_API_PREFIX } from '@/api/client'
import { useSubmitSkillReport } from '@/features/report/use-skill-reports'
@@ -837,7 +838,7 @@ export function SkillDetailPage() {
)}
- {skill.displayName}
+ {skill.displayName}
{skill.ownerDisplayName && (
@@ -1077,6 +1078,8 @@ export function SkillDetailPage() {
+
+
{/* Sidebar */}