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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user