added badge stacking in user popover

This commit is contained in:
Дмитрий Пиченикин 2026-02-24 15:57:02 +03:00
parent dffe0685bb
commit a88ce39a48
4 changed files with 108 additions and 40 deletions

View File

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

View File

@ -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 {# пользователям}}.",

View File

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

View File

@ -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<Props, State> {
}
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<Props, State> {
}
}
groupBadges = (badges: UserBadge[]): BadgeGroup[] => {
const map = new Map<BadgeID, BadgeGroup>();
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<Props, State> {
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<Props, State> {
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 = (
<TooltipWrapper tooltipContent={tooltipLines}>
<a onClick={() => this.onBadgeClick(badge)}>
<BadgeImage
badge={badge}
size={BADGE_SIZE}
/>
<span className='badge-stacked'>
<BadgeImage
badge={badge}
size={BADGE_SIZE}
/>
{count > 1 && (
<span className='badge-stack-count'>
{'×'}{count}
</span>
)}
</span>
</a>
</TooltipWrapper>
);
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 = (
<TooltipWrapper tooltipContent={andMoreText}>