feat(cosmetics): оформление профиля — галерея, роли и личные стили

- миграция 00013: guild_cosmetics (рамки и иконки) и user_styles («для друзей»
  и пер-серверные стили) (AGENT.md 7.2, 7.4)
- store: CRUD галереи, личные стили и вычисление оформления поэлементно
  (роль → пер-серверный стиль → «для друзей» → дефолт) с откатом, когда роль
  больше не выдаёт элемент
- API: галерея сервера под MANAGE_ROLES, оформление роли в create/update,
  GET/PUT/DELETE /users/@me/styles с доступными элементами по областям
- клиент: редактор личного стиля с предпросмотром, галерея оформления в
  настройках сервера, оформление роли в разделе «Роли»
- рендер: рамка под аватаром, иконка рядом с ником, цвет и встроенные эффекты
  ника (CSS, при reduced-motion статично) в ленте и списке участников
This commit is contained in:
2026-09-21 01:28:48 +03:00
parent 2e038a1d3f
commit 48e30a1897
25 changed files with 3542 additions and 41 deletions
+17 -9
View File
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { isPendingMessage, messageTypeOf, type Message } from '@/api/messages';
import { MessageItem } from '@/components/chat/MessageItem';
import type { AuthorDirectory } from '@/components/chat/useAuthorDirectory';
import { Avatar } from '@/components/ui/Avatar';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
@@ -274,25 +274,33 @@ export function MessageList({
) : (
<div className="flex gap-3">
<div className="w-9 shrink-0 pt-0.5">
<Avatar
<FramedAvatar
name={author.name}
seed={author.id === '' ? row.key : author.id}
fileId={author.avatarFileId}
url={author.avatarUrl}
size="md"
cosmetics={author.cosmetics}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col">
{showHeader ? (
<header className="flex flex-wrap items-baseline gap-2">
<span
className="text-sm font-semibold"
style={
author.colorHex === null ? undefined : { color: author.colorHex }
}
{/* Ник: цвет и эффект из оформления важнее цвета роли. */}
<Nickname
name={author.name}
cosmetics={author.cosmetics}
className="text-sm"
>
{author.name}
</span>
{author.colorHex === null ? null : (
<span
aria-hidden="true"
data-testid="role-color-dot"
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: author.colorHex }}
/>
)}
</Nickname>
{headType === 'webhook' ? (
<span
data-testid="webhook-badge"
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { fetchMembers, fetchRoles, guildMembersQueryKey, guildRolesQueryKey } from '@/api/guilds';
import { fetchUser, userQueryKey } from '@/api/users';
import type { GuildMember } from '@/api/types';
import type { GuildMember, MemberCosmetics } from '@/api/types';
import { memberDisplayName, roleColorForMember } from '@/lib/chat';
import { useSessionStore } from '@/stores/session';
@@ -25,6 +25,8 @@ export interface ChatAuthor {
colorHex: string | null;
isInstanceAdmin: boolean;
isMember: boolean;
/** Вычисленное оформление участника на этом сервере (AGENT.md 7.2). */
cosmetics?: MemberCosmetics | undefined;
}
export interface AuthorDirectory {
@@ -159,6 +161,7 @@ export function useAuthorDirectory(guildId: string | null, userIds: string[]): A
colorHex: roleColorForMember(member, rolesData ?? []),
isInstanceAdmin: member.is_instance_admin,
isMember: true,
...(member.cosmetics === undefined ? {} : { cosmetics: member.cosmetics }),
};
}
const fallbackName = userNameMap.get(userId);
+111
View File
@@ -0,0 +1,111 @@
import type { ReactNode } from 'react';
import { Avatar } from '@/components/ui/Avatar';
import { fileContentUrl } from '@/api/files';
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
import type { MemberCosmetics } from '@/api/types';
/**
* Оформление профиля (AGENT.md 7.2, 7.4): рамка вокруг аватара, иконка рядом с
* ником, цвет и встроенный эффект ника. Компоненты общие для ленты, списка
* участников, друзей и профиля — сервер уже применил приоритет оформления.
*/
interface FramedAvatarProps {
name: string;
seed: string;
fileId?: string | undefined;
preview?: string | undefined;
url?: string | undefined;
size?: 'sm' | 'md' | 'lg' | 'xl';
shape?: 'circle' | 'square';
/** Вычисленное оформление: рамка рисуется подложкой под аватаром. */
cosmetics?: MemberCosmetics | undefined;
}
/** Аватар с рамкой: рамка — картинка под аватаром, ник и аватар поверх. */
export function FramedAvatar({
name,
seed,
fileId,
preview,
url,
size = 'md',
shape = 'circle',
cosmetics,
}: FramedAvatarProps) {
const frameId = cosmetics?.frame_id;
if (frameId === undefined || frameId === '') {
return (
<Avatar
name={name}
seed={seed}
fileId={fileId}
preview={preview}
url={url}
size={size}
shape={shape}
/>
);
}
// Рамка крупнее аватара: у неё есть поля по краям.
const scale = { sm: 'h-9 w-9', md: 'h-11 w-11', lg: 'h-16 w-16', xl: 'h-full w-full' }[size];
const inner = { sm: 'h-7 w-7', md: 'h-9 w-9', lg: 'h-14 w-14', xl: 'h-[86%] w-[86%]' }[size];
return (
<span className={`gl-frame ${scale}`} data-testid="avatar-frame">
<img src={fileContentUrl(frameId)} alt="" loading="lazy" className="gl-frame-image" />
<span className={`gl-frame-inner ${inner}`}>
<Avatar
name={name}
seed={seed}
fileId={fileId}
preview={preview}
url={url}
size={size}
shape={shape}
/>
</span>
</span>
);
}
/** Иконка рядом с ником: маленькая картинка из галереи сервера. */
export function NickBadge({ badgeId }: { badgeId: string | undefined }) {
if (badgeId === undefined || badgeId === '') {
return null;
}
return (
<img
src={fileContentUrl(badgeId)}
alt=""
loading="lazy"
data-testid="nick-badge"
className="h-4 w-4 shrink-0"
/>
);
}
interface NicknameProps {
name: string;
cosmetics?: MemberCosmetics | undefined;
className?: string;
/** Дополнительный значок (например системный бейдж администратора). */
children?: ReactNode;
}
/** Ник с цветом, эффектом и иконкой оформления. */
export function Nickname({ name, cosmetics, className = '', children }: NicknameProps) {
return (
<span className={`inline-flex min-w-0 items-center gap-1 ${className}`}>
<span
className={`${nickEffectClass(cosmetics?.nick_effect)} truncate`}
style={nickColorStyle(cosmetics?.nick_color)}
data-testid="nick-name"
>
{name}
</span>
<NickBadge badgeId={cosmetics?.badge_id} />
{children}
</span>
);
}
@@ -0,0 +1,36 @@
import { roleColorHex } from '@/lib/format';
/** Палитра цвета ника: совпадает с серверной (AGENT.md 7.4). */
export const NICK_COLOR_PALETTE = [
0xf2f3f5, 0xa3a6ad, 0xe74c3c, 0xe67e22, 0xf1c40f, 0x2ecc71, 0x1abc9c, 0x3498db, 0x5865f2,
0x9b59b6, 0xe91e63, 0xff7ab8,
];
/**
* Оформление профиля (AGENT.md 7.2, 7.4): встроенные эффекты ника и цвет из
* палитры. Вынесено из компонентов, чтобы файл экспортировал только их.
*/
/** Класс встроенного эффекта ника; неизвестный эффект ничего не добавляет. */
export function nickEffectClass(effect: string | undefined): string {
switch (effect) {
case 'gradient':
case 'glow':
case 'fire':
case 'shimmer':
case 'glitch':
return `gl-nick gl-nick-${effect}`;
default:
return 'gl-nick';
}
}
/** Цвет ника: палитра сервера в виде hex-строки. */
export function nickColorStyle(color: number | undefined): { color: string } | undefined {
if (color === undefined) {
return undefined;
}
// Цвет ника — из палитры; 0 означает «как у роли».
const hex = roleColorHex(color);
return hex === null ? undefined : { color: hex };
}