From a88ce39a4894170a6fe73f724d2bac514bb5bb60 Mon Sep 17 00:00:00 2001 From: "dmitrii.pichenikin" Date: Tue, 24 Feb 2026 15:57:02 +0300 Subject: [PATCH] added badge stacking in user popover --- webapp/i18n/en.json | 1 + webapp/i18n/ru.json | 1 + .../components/user_popover/badge_list.scss | 22 ++++ .../components/user_popover/badge_list.tsx | 124 ++++++++++++------ 4 files changed, 108 insertions(+), 40 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index f6c0db6..4c03c40 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -19,6 +19,7 @@ "badges.label.granted_by": "Granted by: {username}", "badges.label.granted_at": "Granted at: {date}", "badges.label.reason": "Why? {reason}", + "badges.label.count": "Count: {count}", "badges.granted.not_yet": "Not yet granted.", "badges.granted.multiple": "Granted {times, plural, one {# time} other {# times}} to {users, plural, one {# user} other {# users}}.", diff --git a/webapp/i18n/ru.json b/webapp/i18n/ru.json index d69f068..a9902c6 100644 --- a/webapp/i18n/ru.json +++ b/webapp/i18n/ru.json @@ -19,6 +19,7 @@ "badges.label.granted_by": "Выдал: {username}", "badges.label.granted_at": "Выдан: {date}", "badges.label.reason": "Причина: {reason}", + "badges.label.count": "Количество: {count}", "badges.granted.not_yet": "Ещё не выдан.", "badges.granted.multiple": "Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.", diff --git a/webapp/src/components/user_popover/badge_list.scss b/webapp/src/components/user_popover/badge_list.scss index c4b81dc..51144f1 100644 --- a/webapp/src/components/user_popover/badge_list.scss +++ b/webapp/src/components/user_popover/badge_list.scss @@ -3,6 +3,7 @@ display: flex; align-content: flex-end; align-items: center; + gap: 6px; } #showMoreButton { @@ -23,6 +24,27 @@ } } + .badge-stacked { + position: relative; + display: inline-block; + } + + .badge-stack-count { + position: absolute; + bottom: -2px; + right: -4px; + background: var(--button-bg, #166de0); + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 1; + padding: 1px 3px; + border-radius: 6px; + min-width: 14px; + text-align: center; + pointer-events: none; + } + #grantBadgeButton { margin-top: 4px; padding-left: 0; diff --git a/webapp/src/components/user_popover/badge_list.tsx b/webapp/src/components/user_popover/badge_list.tsx index b0eafc2..f3f9234 100644 --- a/webapp/src/components/user_popover/badge_list.tsx +++ b/webapp/src/components/user_popover/badge_list.tsx @@ -32,6 +32,11 @@ type Props = { }; } +type BadgeGroup = { + badge: UserBadge; + count: number; +} + type State = { badges?: UserBadge[]; loaded?: boolean; @@ -55,12 +60,12 @@ class BadgeList extends React.PureComponent { } componentDidUpdate(prevProps: Props, prevState: State) { - if (this.state.badges !== prevState.badges) { - const nBadges = this.state.badges?.length || 0; - const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES; + if (this.state.badges !== prevState.badges && this.state.badges) { + const groups = this.groupBadges(this.state.badges); + const toShow = Math.min(groups.length, MAX_BADGES); const names: string[] = []; for (let i = 0; i < toShow; i++) { - const badge = this.state.badges![i]; + const {badge} = groups[i]; if (badge.image_type === IMAGE_TYPE_EMOJI) { names.push(badge.image); } @@ -70,6 +75,19 @@ class BadgeList extends React.PureComponent { } } + groupBadges = (badges: UserBadge[]): BadgeGroup[] => { + const map = new Map(); + for (const badge of badges) { + const existing = map.get(badge.id); + if (existing) { + existing.count++; + } else { + map.set(badge.id, {badge, count: 1}); + } + } + return Array.from(map.values()); + } + onMoreClick = () => { if (!this.props.openRHS) { return; @@ -78,6 +96,7 @@ class BadgeList extends React.PureComponent { if (this.props.currentUserID === this.props.user.id) { this.props.actions.setRHSView(RHS_STATE_MY); this.props.openRHS(); + this.props.hide(); return; } @@ -105,58 +124,83 @@ class BadgeList extends React.PureComponent { render() { const {intl} = this.props; - const nBadges = this.state.badges?.length || 0; - const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES; + const groups = this.state.badges ? this.groupBadges(this.state.badges) : []; + const nGroups = groups.length; + const toShow = Math.min(nGroups, MAX_BADGES); + + const nameLabel = intl.formatMessage( + {id: 'badges.label.name', defaultMessage: 'Название:'}, + ); + const descLabel = intl.formatMessage( + {id: 'badges.label.description', defaultMessage: 'Описание:'}, + ); const content: React.ReactNode[] = []; for (let i = 0; i < toShow; i++) { - const badge = this.state.badges![i]; - const time = new Date(badge.time); - const nameLabel = intl.formatMessage( - {id: 'badges.label.name', defaultMessage: 'Название:'}, - ); - const descLabel = intl.formatMessage( - {id: 'badges.label.description', defaultMessage: 'Описание:'}, - ); - let reason: string | null = null; - if (badge.reason) { - reason = intl.formatMessage( - {id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'}, - {reason: badge.reason}, + const {badge, count} = groups[i]; + + let tooltipLines: string; + if (count > 1) { + const countLabel = intl.formatMessage( + {id: 'badges.label.count', defaultMessage: 'Количество: {count}'}, + {count}, ); + tooltipLines = [ + nameLabel + ' ' + badge.name, + descLabel + ' ' + badge.description, + countLabel, + ].join('\n'); + } else { + const time = new Date(badge.time); + let reason: string | null = null; + if (badge.reason) { + reason = intl.formatMessage( + {id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'}, + {reason: badge.reason}, + ); + } + const grantedBy = intl.formatMessage( + {id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'}, + {username: badge.granted_by_name}, + ); + const grantedAt = intl.formatMessage( + {id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'}, + {date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}, + ); + tooltipLines = [ + nameLabel + ' ' + badge.name, + descLabel + ' ' + badge.description, + reason, + grantedBy, + grantedAt, + ].filter(Boolean).join('\n'); } - const grantedBy = intl.formatMessage( - {id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'}, - {username: badge.granted_by_name}, - ); - const grantedAt = intl.formatMessage( - {id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'}, - {date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}, - ); - const tooltipLines = [ - nameLabel + ' ' + badge.name, - descLabel + ' ' + badge.description, - reason, - grantedBy, - grantedAt, - ].filter(Boolean).join('\n'); + const badgeComponent = ( this.onBadgeClick(badge)}> - + + + {count > 1 && ( + + {'×'}{count} + + )} + ); content.push(badgeComponent); } + let andMore: React.ReactNode = null; - if (nBadges > MAX_BADGES) { + if (nGroups > MAX_BADGES) { const andMoreText = intl.formatMessage( {id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'}, - {count: nBadges - MAX_BADGES}, + {count: nGroups - MAX_BADGES}, ); andMore = (