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
+248
View File
@@ -0,0 +1,248 @@
import { ApiError, isApiErrorPayload, parsePayload, request } from './client';
import type { MemberCosmetics } from './types';
/**
* Оформление профиля (AGENT.md §7.2, §7.4): галерея сервера (рамки и иконки),
* оформление ролей и личные стили пользователя. Сервер отдаёт вычисленное
* оформление в списке участников, здесь — управление галереей и стилями.
*/
export type CosmeticKind = 'frame' | 'badge';
export interface GuildCosmetic {
id: string;
guild_id: string;
kind: CosmeticKind;
name: string;
file_id: string;
created_at: string;
}
/** Лимиты сервера: галерея ≤ 50 элементов, файл ≤ 5 МБ (AGENT.md 7.4). */
export const COSMETIC_MAX_BYTES = 5 * 1024 * 1024;
export const COSMETICS_MAX_PER_GUILD = 50;
export const COSMETIC_NAME_MAX = 40;
/** Встроенный набор эффектов ника; порядок совпадает с серверным. */
export const NICK_EFFECTS = ['none', 'gradient', 'glow', 'fire', 'shimmer', 'glitch'] as const;
export type NickEffect = (typeof NICK_EFFECTS)[number];
export const guildCosmeticsQueryKey = (guildId: string) =>
['guilds', guildId, 'cosmetics'] as const;
export const myStylesQueryKey = ['users', '@me', 'styles'] as const;
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value !== '' ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' ? value : undefined;
}
function parseCosmetic(value: unknown): GuildCosmetic | null {
const record = asRecord(value);
const id = asString(record?.['id']);
const fileId = asString(record?.['file_id']);
const kind = record?.['kind'];
if (record === null || id === undefined || fileId === undefined) {
return null;
}
if (kind !== 'frame' && kind !== 'badge') {
return null;
}
return {
id,
guild_id: asString(record['guild_id']) ?? '',
kind,
name: asString(record['name']) ?? kind,
file_id: fileId,
created_at: asString(record['created_at']) ?? '',
};
}
function parseCosmetics(payload: unknown): GuildCosmetic[] {
const record = asRecord(payload);
const list = record?.['cosmetics'];
if (!Array.isArray(list)) {
return [];
}
return list.map(parseCosmetic).filter((item): item is GuildCosmetic => item !== null);
}
/** `GET /guilds/{id}/cosmetics` — галерея сервера. */
export async function fetchGuildCosmetics(
guildId: string,
signal?: AbortSignal,
): Promise<GuildCosmetic[]> {
const payload = await request<unknown>(
`/guilds/${encodeURIComponent(guildId)}/cosmetics`,
signal === undefined ? {} : { signal },
);
return parseCosmetics(payload);
}
/**
* `POST /guilds/{id}/cosmetics` — загрузка рамки или иконки (multipart, право
* MANAGE_ROLES). Тело — форма, поэтому запрос собирается напрямую.
*/
export async function uploadGuildCosmetic(
guildId: string,
input: { kind: CosmeticKind; name: string; file: File },
): Promise<GuildCosmetic> {
const form = new FormData();
form.append('kind', input.kind);
form.append('name', input.name);
form.append('file', input.file);
const response = await fetch(`/api/v1/guilds/${encodeURIComponent(guildId)}/cosmetics`, {
method: 'POST',
credentials: 'same-origin',
headers: { Accept: 'application/json' },
body: form,
});
const payload = parsePayload(await response.text(), response.headers.get('Content-Type'));
if (!response.ok) {
const envelope = asRecord(payload)?.['error'];
if (isApiErrorPayload(envelope)) {
throw new ApiError(response.status, envelope.code, envelope.message, envelope.details);
}
throw new ApiError(response.status, 'internal.error', 'unexpected response');
}
const cosmetic = parseCosmetic(asRecord(payload)?.['cosmetic']);
if (cosmetic === null) {
throw new ApiError(response.status, 'internal.error', 'unexpected response');
}
return cosmetic;
}
/** `PATCH /guilds/{id}/cosmetics/{cid}` — переименовать элемент галереи. */
export async function renameGuildCosmetic(
guildId: string,
cosmeticId: string,
name: string,
): Promise<GuildCosmetic> {
const payload = await request<unknown>(
`/guilds/${encodeURIComponent(guildId)}/cosmetics/${encodeURIComponent(cosmeticId)}`,
{ method: 'PATCH', body: { name } },
);
const cosmetic = parseCosmetic(asRecord(payload)?.['cosmetic']);
if (cosmetic === null) {
throw new Error('malformed cosmetic payload');
}
return cosmetic;
}
/** `DELETE /guilds/{id}/cosmetics/{cid}` — убрать элемент из галереи. */
export async function deleteGuildCosmetic(guildId: string, cosmeticId: string): Promise<void> {
await request(
`/guilds/${encodeURIComponent(guildId)}/cosmetics/${encodeURIComponent(cosmeticId)}`,
{ method: 'DELETE' },
);
}
/** Личный стиль профиля вместе с доступными элементами. */
export interface UserStyle {
/** `global` — «для друзей», иначе идентификатор сервера. */
scope: string;
guild_id?: string;
/** Имя сервера для подписи области (сервер отдаёт его вместе со стилем). */
guild_name?: string;
style: MemberCosmetics;
frame_ids: string[];
badge_ids: string[];
nick_colors: number[];
effects: NickEffect[];
}
export interface StyleInput {
frame_id?: string;
badge_id?: string;
nick_color?: number;
nick_effect?: NickEffect;
}
function parseStyle(value: unknown): UserStyle | null {
const record = asRecord(value);
const scope = asString(record?.['scope']);
if (record === null || scope === undefined) {
return null;
}
const style = asRecord(record['style']) ?? {};
const cosmetics: MemberCosmetics = {};
const frame = asString(style['frame_id']);
const badge = asString(style['badge_id']);
const color = asNumber(style['nick_color']);
const effect = asString(style['nick_effect']);
if (frame !== undefined) {
cosmetics.frame_id = frame;
}
if (badge !== undefined) {
cosmetics.badge_id = badge;
}
if (color !== undefined) {
cosmetics.nick_color = color;
}
if (effect !== undefined) {
cosmetics.nick_effect = effect;
}
const stringList = (input: unknown): string[] =>
Array.isArray(input) ? input.filter((item): item is string => typeof item === 'string') : [];
const numberList = (input: unknown): number[] =>
Array.isArray(input) ? input.filter((item): item is number => typeof item === 'number') : [];
const effectList = (input: unknown): NickEffect[] =>
Array.isArray(input)
? input.filter((item): item is NickEffect => NICK_EFFECTS.includes(item as NickEffect))
: [];
const parsed: UserStyle = {
scope,
style: cosmetics,
frame_ids: stringList(record['frame_ids']),
badge_ids: stringList(record['badge_ids']),
nick_colors: numberList(record['nick_colors']),
effects: effectList(record['effects']),
};
const guildId = asString(record['guild_id']);
const guildName = asString(record['guild_name']);
if (guildId !== undefined) {
parsed.guild_id = guildId;
}
if (guildName !== undefined) {
parsed.guild_name = guildName;
}
return parsed;
}
/** `GET /users/@me/styles` — личные стили и доступные элементы. */
export async function fetchMyStyles(signal?: AbortSignal): Promise<UserStyle[]> {
const payload = await request<unknown>(
'/users/@me/styles',
signal === undefined ? {} : { signal },
);
const list = asRecord(payload)?.['styles'];
if (!Array.isArray(list)) {
return [];
}
return list.map(parseStyle).filter((item): item is UserStyle => item !== null);
}
/** `PUT /users/@me/styles/{scope}` — задать стиль области. */
export async function putMyStyle(scope: string, input: StyleInput): Promise<UserStyle> {
const payload = await request<unknown>(`/users/@me/styles/${encodeURIComponent(scope)}`, {
method: 'PUT',
body: input,
});
const style = parseStyle(asRecord(payload)?.['style']);
if (style === null) {
throw new Error('malformed style payload');
}
return style;
}
/** `DELETE /users/@me/styles/{scope}` — сбросить стиль области. */
export async function deleteMyStyle(scope: string): Promise<void> {
await request(`/users/@me/styles/${encodeURIComponent(scope)}`, { method: 'DELETE' });
}
+24
View File
@@ -73,6 +73,30 @@ export async function fetchRoles(guildId: string, signal?: AbortSignal): Promise
return payload.roles;
}
/** Оформление роли в запросе правки (AGENT.md 7.4). */
export interface RoleDecorationUpdate {
cosmetic_frame_id?: string;
cosmetic_badge_id?: string;
nick_color?: number;
nick_effect?: string;
}
/**
* `PATCH /guilds/{id}/roles/{rid}` — правка роли. Клиент меняет только
* оформление: имя, права и позицию редактирует панель ролей.
*/
export async function updateRole(
guildId: string,
roleId: string,
body: RoleDecorationUpdate,
): Promise<Role> {
const payload = await request<{ role: Role }>(
`/guilds/${encodeURIComponent(guildId)}/roles/${encodeURIComponent(roleId)}`,
{ method: 'PATCH', body },
);
return payload.role;
}
export function joinGuild(guildId: string): Promise<{ ok: true }> {
return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' });
}
+18
View File
@@ -61,6 +61,11 @@ export interface Role {
is_default: boolean;
hoist: boolean;
mentionable: boolean;
/** Оформление роли (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. */
cosmetic_frame_id?: string;
cosmetic_badge_id?: string;
nick_color?: number;
nick_effect?: string;
}
export interface Channel {
@@ -129,6 +134,19 @@ export interface GuildMember {
is_instance_admin: boolean;
joined_at: string;
role_ids: string[];
/** Вычисленное оформление участника на сервере (AGENT.md 7.2). */
cosmetics?: MemberCosmetics;
}
/**
* Вычисленное оформление: сервер уже применил приоритет «роль → пер-серверный
* стиль → стиль для друзей → дефолт», клиент только рисует.
*/
export interface MemberCosmetics {
frame_id?: string;
badge_id?: string;
nick_color?: number;
nick_effect?: string;
}
export interface InstanceInfo {
+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 };
}
+46 -1
View File
@@ -676,6 +676,31 @@
"hint": "Profile badges and frames.",
"empty": "No decorations yet.",
"stub": "Badges, frames and profile effects are coming later."
},
"style": {
"title": "Profile decoration",
"description": "Frame, icon, nick colour and effect. You can only pick what your roles grant: on a server the role wins, then the per-server style, then the friends style.",
"scope": "Where to apply",
"scopeGlobal": "For friends (DMs and global places)",
"preview": "preview",
"frame": "Avatar frame",
"badge": "Icon next to the nick",
"color": "Nick colour",
"colorDefault": "Same as role",
"effect": "Nick effect",
"none": "No element",
"noFrames": "No frames granted by roles yet — a server admin grants them.",
"noBadges": "No icons granted by roles yet.",
"reset": "Reset style",
"priorityHint": "Changes apply immediately.",
"effects": {
"none": "No effect",
"gradient": "Gradient",
"glow": "Glow",
"fire": "Fire",
"shimmer": "Shimmer",
"glitch": "Glitch"
}
}
},
"security": {
@@ -842,7 +867,12 @@
"description": "Role colour and position. Role management arrives in the next phases.",
"empty": "This server has no roles yet.",
"position": "Position {{position}}",
"default": "default"
"default": "default",
"frame": "Frame",
"badge": "Icon",
"nickColor": "Nick colour",
"nickEffect": "Nick effect",
"decorationSaved": "Role decoration saved."
},
"danger": {
"title": "Danger zone",
@@ -986,6 +1016,21 @@
"empty": "none",
"notImage": "An image file is required (PNG, JPEG, GIF, WebP).",
"tooLarge": "The file is larger than 8 MB."
},
"cosmetics": {
"title": "Decoration gallery",
"description": "Avatar frames and icons next to the nick. Uploaded items are granted to roles in the Roles section; animated GIF and WebP keep their animation.",
"upload": "Upload",
"empty": "Nothing uploaded yet.",
"limit": "{{used}} of {{total}} used",
"name": "Item name",
"notImage": "An image file is required",
"tooLarge": "File is larger than 5 MB",
"deleteConfirm": "Delete the item? It disappears from all roles and personal styles.",
"kind": {
"frame": "Frames",
"badge": "Icons"
}
}
},
"navLabel": "Settings sections",
+46 -1
View File
@@ -676,6 +676,31 @@
"hint": "Бейджи и рамки профиля.",
"empty": "Украшений пока нет.",
"stub": "Бейджи, рамки и эффекты профиля появятся позже."
},
"style": {
"title": "Оформление профиля",
"description": "Рамка, иконка, цвет и эффект ника. Выбирать можно только то, что выдали роли: на сервере приоритет у роли, затем у пер-серверного стиля, затем у стиля «для друзей».",
"scope": "Где применять",
"scopeGlobal": "Для друзей (DM и глобальные места)",
"preview": "предпросмотр",
"frame": "Рамка аватара",
"badge": "Иконка рядом с ником",
"color": "Цвет ника",
"colorDefault": "Как у роли",
"effect": "Эффект ника",
"none": "Без элемента",
"noFrames": "Роли пока не выдали ни одной рамки — их выдаёт администратор сервера.",
"noBadges": "Роли пока не выдали ни одной иконки.",
"reset": "Сбросить стиль",
"priorityHint": "Изменения применяются сразу.",
"effects": {
"none": "Без эффекта",
"gradient": "Градиент",
"glow": "Свечение",
"fire": "Огонь",
"shimmer": "Блик",
"glitch": "Глитч"
}
}
},
"security": {
@@ -842,7 +867,12 @@
"description": "Цвет и позиция ролей. Управление ролями появится в следующих фазах.",
"empty": "На сервере пока нет ролей.",
"position": "Позиция {{position}}",
"default": "по умолчанию"
"default": "по умолчанию",
"frame": "Рамка",
"badge": "Иконка",
"nickColor": "Цвет ника",
"nickEffect": "Эффект ника",
"decorationSaved": "Оформление роли сохранено."
},
"danger": {
"title": "Опасная зона",
@@ -986,6 +1016,21 @@
"empty": "нет",
"notImage": "Нужен файл изображения (PNG, JPEG, GIF, WebP).",
"tooLarge": "Файл больше 8 МБ."
},
"cosmetics": {
"title": "Галерея оформления",
"description": "Рамки аватара и иконки рядом с ником. Загруженные элементы выдаются ролям в разделе «Роли»; анимированные GIF и WebP сохраняют анимацию.",
"upload": "Загрузить",
"empty": "Пока ничего не загружено.",
"limit": "занято {{used}} из {{total}}",
"name": "Имя элемента",
"notImage": "Нужен файл изображения",
"tooLarge": "Файл больше 5 МБ",
"deleteConfirm": "Удалить элемент? Он исчезнет у всех ролей и в личных стилях.",
"kind": {
"frame": "Рамки",
"badge": "Иконки"
}
}
},
"navLabel": "Пункты настроек",
+121
View File
@@ -422,3 +422,124 @@ body {
scroll-behavior: auto !important;
}
}
/*
* Эффекты ника (AGENT.md 7.4): встроенный набор CSS-анимаций. При
* `prefers-reduced-motion: reduce` анимации гасятся общим правилом выше,
* поэтому эффект остаётся статичным градиентом или свечением.
*/
.gl-nick {
font-weight: 600;
}
.gl-nick-gradient {
background-image: linear-gradient(90deg, #5865f2, #eb459e, #faa61a, #5865f2);
background-size: 300% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gl-nick-gradient 6s linear infinite;
}
@keyframes gl-nick-gradient {
from {
background-position: 0% 50%;
}
to {
background-position: 300% 50%;
}
}
.gl-nick-glow {
animation: gl-nick-glow 2.4s ease-in-out infinite;
}
@keyframes gl-nick-glow {
0%,
100% {
text-shadow: 0 0 4px currentColor;
}
50% {
text-shadow:
0 0 10px currentColor,
0 0 18px currentColor;
}
}
.gl-nick-fire {
background-image: linear-gradient(180deg, #ffd166, #ff7b00, #d62828);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gl-nick-fire 1.6s ease-in-out infinite;
}
@keyframes gl-nick-fire {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(1.25);
}
}
.gl-nick-shimmer {
background-image: linear-gradient(100deg, currentColor 20%, #ffffff 40%, currentColor 60%);
background-size: 250% 100%;
-webkit-background-clip: text;
background-clip: text;
animation: gl-nick-shimmer 3.2s linear infinite;
}
@keyframes gl-nick-shimmer {
from {
background-position: 150% 50%;
}
to {
background-position: -50% 50%;
}
}
.gl-nick-glitch {
animation: gl-nick-glitch 2.2s steps(2, end) infinite;
}
@keyframes gl-nick-glitch {
0%,
92%,
100% {
text-shadow: none;
}
93% {
text-shadow: -2px 0 #eb459e;
}
95% {
text-shadow: 2px 0 #00b0f4;
}
97% {
text-shadow: -1px 0 #faa61a;
}
}
/* Рамка вокруг аватара: подложка под аватаром, как в AGENT.md 7.4. */
.gl-frame {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.gl-frame-image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.gl-frame-inner {
position: relative;
z-index: 1;
}
+20
View File
@@ -53,6 +53,7 @@ export const PERMISSION_MUTE_MEMBERS = 'MUTE_MEMBERS';
export const PERMISSION_DEAFEN_MEMBERS = 'DEAFEN_MEMBERS';
export const PERMISSION_MOVE_MEMBERS = 'MOVE_MEMBERS';
export const PERMISSION_MANAGE_WEBHOOKS = 'MANAGE_WEBHOOKS';
export const PERMISSION_MANAGE_ROLES = 'MANAGE_ROLES';
/** Есть ли у пользователя право с указанным именем. */
export function hasPermission(permissions: readonly string[], name: string): boolean {
@@ -191,6 +192,25 @@ export function canManageEmojis(
);
}
/**
* Может ли пользователь управлять ролями и галереей оформления (AGENT.md 7.4):
* владелец, ADMINISTRATOR, MANAGE_ROLES или администратор инстанса.
*/
export function canManageRoles(
permissions: readonly string[],
ownerId: string,
userId: string | undefined,
isInstanceAdmin = false,
): boolean {
if (isInstanceAdmin || (userId !== undefined && userId === ownerId)) {
return true;
}
return (
hasPermission(permissions, PERMISSION_ADMINISTRATOR) ||
hasPermission(permissions, PERMISSION_MANAGE_ROLES)
);
}
/**
* Может ли пользователь управлять вебхуками комнаты (AGENT.md 7.11):
* владелец, ADMINISTRATOR, MANAGE_WEBHOOKS или администратор инстанса.
@@ -0,0 +1,231 @@
import { useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import {
COSMETICS_MAX_PER_GUILD,
COSMETIC_MAX_BYTES,
COSMETIC_NAME_MAX,
deleteGuildCosmetic,
fetchGuildCosmetics,
guildCosmeticsQueryKey,
renameGuildCosmetic,
uploadGuildCosmetic,
type CosmeticKind,
type GuildCosmetic,
} from '@/api/cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { SettingsSection } from '@/pages/settings/SettingsSection';
interface GuildCosmeticsSectionProps {
guildId: string;
canManage: boolean;
}
/**
* «Оформление» сервера (AGENT.md 7.4): галерея рамок и иконок. Файлы хранятся
* обычными загрузками, поэтому анимированные GIF/WebP сохраняют анимацию;
* элементы выдаются ролям в разделе «Роли».
*/
export function GuildCosmeticsSection({ guildId, canManage }: GuildCosmeticsSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const frameInput = useRef<HTMLInputElement | null>(null);
const badgeInput = useRef<HTMLInputElement | null>(null);
const [renaming, setRenaming] = useState<GuildCosmetic | null>(null);
const [name, setName] = useState('');
const [error, setError] = useState<unknown>(null);
const cosmetics = useQuery({
queryKey: guildCosmeticsQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildCosmetics(guildId, signal),
enabled: canManage,
retry: 0,
});
const invalidate = (): void => {
void queryClient.invalidateQueries({ queryKey: guildCosmeticsQueryKey(guildId) });
};
const upload = useMutation({
mutationFn: (input: { kind: CosmeticKind; name: string; file: File }) =>
uploadGuildCosmetic(guildId, input),
onSuccess: () => {
setError(null);
invalidate();
},
onError: (cause: unknown) => setError(cause),
});
const rename = useMutation({
mutationFn: (input: { id: string; name: string }) =>
renameGuildCosmetic(guildId, input.id, input.name),
onSuccess: () => {
setRenaming(null);
invalidate();
},
onError: (cause: unknown) => setError(cause),
});
const remove = useMutation({
mutationFn: (id: string) => deleteGuildCosmetic(guildId, id),
onSuccess: invalidate,
onError: (cause: unknown) => setError(cause),
});
if (!canManage) {
return null;
}
/** Проверяем файл на клиенте: сервер всё равно проверит, но так быстрее. */
const pick = (kind: CosmeticKind, file: File | undefined): void => {
if (file === undefined) {
return;
}
if (!file.type.startsWith('image/')) {
setError(new Error(t('settings.servers.cosmetics.notImage')));
return;
}
if (file.size > COSMETIC_MAX_BYTES) {
setError(new Error(t('settings.servers.cosmetics.tooLarge')));
return;
}
upload.mutate({ kind, name: file.name.replace(/\.[^.]+$/u, ''), file });
};
const list = cosmetics.data ?? [];
const frames = list.filter((item) => item.kind === 'frame');
const badges = list.filter((item) => item.kind === 'badge');
return (
<SettingsSection
title={t('settings.servers.cosmetics.title')}
description={t('settings.servers.cosmetics.description')}
>
<div className="mt-4 flex flex-col gap-3" data-testid="guild-cosmetics">
{cosmetics.isPending ? <SkeletonLines rows={2} /> : null}
{cosmetics.isError ? (
<ErrorNotice error={cosmetics.error} onRetry={() => void cosmetics.refetch()} />
) : null}
{error === null ? null : <ErrorNotice error={error} />}
<p className="text-xs text-fg-muted">
{t('settings.servers.cosmetics.limit', {
used: list.length,
total: COSMETICS_MAX_PER_GUILD,
})}
</p>
{[
{ kind: 'frame' as const, items: frames, input: frameInput },
{ kind: 'badge' as const, items: badges, input: badgeInput },
].map((group) => (
<div key={group.kind} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{t(`settings.servers.cosmetics.kind.${group.kind}`)}
</span>
<Button
variant="ghost"
data-testid={`cosmetic-upload-${group.kind}`}
disabled={upload.isPending || list.length >= COSMETICS_MAX_PER_GUILD}
onClick={() => group.input.current?.click()}
>
{t('settings.servers.cosmetics.upload')}
</Button>
<input
ref={group.input}
type="file"
accept="image/*"
aria-label={t(`settings.servers.cosmetics.kind.${group.kind}`)}
data-testid={`cosmetic-input-${group.kind}`}
className="sr-only"
onChange={(event) => {
pick(group.kind, event.target.files?.[0]);
event.target.value = '';
}}
/>
</div>
{group.items.length === 0 ? (
<p className="text-xs text-fg-muted">{t('settings.servers.cosmetics.empty')}</p>
) : (
<ul className="flex flex-wrap gap-2" data-testid={`cosmetic-list-${group.kind}`}>
{group.items.map((item) => (
<li
key={item.id}
data-testid={`cosmetic-${item.id}`}
className="flex w-40 flex-col items-center gap-1 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-2"
>
<img
src={fileContentUrl(item.file_id)}
alt=""
loading="lazy"
className="h-14 w-14 object-contain"
/>
<span className="w-full truncate text-center text-xs" title={item.name}>
{item.name}
</span>
<div className="flex gap-1">
<Button
variant="ghost"
data-testid={`cosmetic-rename-${item.id}`}
onClick={() => {
setRenaming(item);
setName(item.name);
}}
>
{t('settings.channel.webhooks.rename')}
</Button>
<Button
variant="ghost"
className="border border-danger/50 text-danger"
data-testid={`cosmetic-delete-${item.id}`}
disabled={remove.isPending}
onClick={() => {
if (window.confirm(t('settings.servers.cosmetics.deleteConfirm'))) {
remove.mutate(item.id);
}
}}
>
{t('common.delete')}
</Button>
</div>
</li>
))}
</ul>
)}
</div>
))}
{renaming === null ? null : (
<form
className="flex items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
if (name.trim() !== '') {
rename.mutate({ id: renaming.id, name: name.trim() });
}
}}
>
<Field
label={t('settings.servers.cosmetics.name')}
value={name}
maxLength={COSMETIC_NAME_MAX}
onChange={(event) => setName(event.target.value)}
/>
<Button type="submit" disabled={rename.isPending}>
{t('common.save')}
</Button>
<Button variant="ghost" onClick={() => setRenaming(null)}>
{t('common.cancel')}
</Button>
</form>
)}
</div>
</SettingsSection>
);
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import type { GuildMember } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Badge } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
@@ -63,14 +63,20 @@ export function GuildMembersSection({
key={member.user_id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
<Avatar
<FramedAvatar
name={member.display_name}
seed={member.user_id}
fileId={member.avatar_file_id}
size="sm"
cosmetics={member.cosmetics}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{name}</span>
{/* Оформление участника приходит уже вычисленным (AGENT.md 7.2). */}
<Nickname
name={name}
cosmetics={member.cosmetics}
className="max-w-full text-sm font-medium"
/>
<span className="block truncate text-xs text-fg-muted">
{member.nickname === undefined || member.nickname === ''
? `@${member.username}`
+202 -17
View File
@@ -1,19 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchRoles, guildRolesQueryKey } from '@/api/guilds';
import { fetchRoles, guildRolesQueryKey, updateRole } from '@/api/guilds';
import {
NICK_EFFECTS,
fetchGuildCosmetics,
guildCosmeticsQueryKey,
type GuildCosmetic,
type NickEffect,
} from '@/api/cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { NICK_COLOR_PALETTE } from '@/components/profile/cosmeticsStyle';
import { roleColorHex } from '@/lib/format';
import { SettingsSection } from '@/pages/settings/SettingsSection';
import type { Role } from '@/api/types';
interface GuildRolesSectionProps {
guildId: string;
/** Право MANAGE_ROLES: без него оформление роли показывается только для чтения. */
canManage?: boolean;
}
/** «Роли»: цвет и позиция. В Фазе 1 — только чтение. */
export function GuildRolesSection({ guildId }: GuildRolesSectionProps) {
/**
* «Роли»: список ролей и — при праве MANAGE_ROLES — оформление роли
* (AGENT.md 7.4): рамка, иконка, цвет и эффект ника. Рамки и иконки берутся из
* галереи сервера, поэтому она читается тем же разделом.
*/
export function GuildRolesSection({ guildId, canManage = false }: GuildRolesSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const roles = useQuery({
queryKey: guildRolesQueryKey(guildId),
@@ -21,7 +37,25 @@ export function GuildRolesSection({ guildId }: GuildRolesSectionProps) {
retry: 0,
});
const cosmetics = useQuery({
queryKey: guildCosmeticsQueryKey(guildId),
queryFn: ({ signal }) => fetchGuildCosmetics(guildId, signal),
retry: 0,
});
const patch = useMutation({
mutationFn: (input: { roleId: string; body: RoleDecorationInput }) =>
updateRole(guildId, input.roleId, input.body),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: guildRolesQueryKey(guildId) });
// Оформление участников в ленте пересчитывается из списка участников.
void queryClient.invalidateQueries({ queryKey: ['guilds', guildId, 'members'] });
},
});
const sorted = (roles.data ?? []).slice().sort((left, right) => right.position - left.position);
const frames = (cosmetics.data ?? []).filter((item) => item.kind === 'frame');
const badges = (cosmetics.data ?? []).filter((item) => item.kind === 'badge');
return (
<SettingsSection
@@ -35,33 +69,184 @@ export function GuildRolesSection({ guildId }: GuildRolesSectionProps) {
) : sorted.length === 0 ? (
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.roles.empty')}</p>
) : (
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-roles">
<ul className="gl-stagger mt-4 flex flex-col gap-2" data-testid="guild-roles">
{sorted.map((role) => {
const color = roleColorHex(role.color);
return (
<li
key={role.id}
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
data-testid={`role-${role.id}`}
className="flex flex-col gap-2 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
<span
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 rounded-full border border-border/60"
style={color === null ? undefined : { backgroundColor: color }}
/>
<span className="min-w-0 flex-1 truncate text-sm">{role.name}</span>
{role.is_default ? (
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 rounded-full border border-border/60"
style={color === null ? undefined : { backgroundColor: color }}
/>
<span className="min-w-0 flex-1 truncate text-sm">{role.name}</span>
{role.is_default ? (
<span className="text-xs text-fg-muted">
{t('settings.servers.roles.default')}
</span>
) : null}
<span className="text-xs text-fg-muted">
{t('settings.servers.roles.default')}
{t('settings.servers.roles.position', { position: role.position })}
</span>
</div>
{canManage ? (
<RoleDecoration
role={role}
frames={frames}
badges={badges}
disabled={patch.isPending}
onChange={(body) => patch.mutate({ roleId: role.id, body })}
/>
) : null}
<span className="text-xs text-fg-muted">
{t('settings.servers.roles.position', { position: role.position })}
</span>
</li>
);
})}
</ul>
)}
{patch.isError ? <ErrorNotice className="mt-3" error={patch.error} /> : null}
{patch.isSuccess ? (
<p className="mt-3 text-sm text-success" role="status">
{t('settings.servers.roles.decorationSaved')}
</p>
) : null}
</SettingsSection>
);
}
/** Тело запроса правки роли: только оформление. */
export interface RoleDecorationInput {
cosmetic_frame_id?: string;
cosmetic_badge_id?: string;
nick_color?: number;
nick_effect?: NickEffect;
}
interface RoleDecorationProps {
role: Role;
frames: GuildCosmetic[];
badges: GuildCosmetic[];
disabled: boolean;
onChange: (body: RoleDecorationInput) => void;
}
/** Оформление роли: рамка, иконка, цвет и эффект ника. */
function RoleDecoration({ role, frames, badges, disabled, onChange }: RoleDecorationProps) {
const { t } = useTranslation();
return (
<div
className="flex flex-wrap items-center gap-2 pl-6"
data-testid={`role-decoration-${role.id}`}
>
<DecorationSelect
label={t('settings.servers.roles.frame')}
testId={`role-frame-${role.id}`}
value={role.cosmetic_frame_id ?? ''}
disabled={disabled}
options={frames.map((item) => ({ id: item.id, label: item.name }))}
emptyLabel={t('settings.profile.style.none')}
onChange={(value) => onChange({ cosmetic_frame_id: value })}
/>
<DecorationSelect
label={t('settings.servers.roles.badge')}
testId={`role-badge-${role.id}`}
value={role.cosmetic_badge_id ?? ''}
disabled={disabled}
options={badges.map((item) => ({ id: item.id, label: item.name }))}
emptyLabel={t('settings.profile.style.none')}
onChange={(value) => onChange({ cosmetic_badge_id: value })}
/>
<label className="flex items-center gap-1 text-xs">
<span className="text-fg-muted">{t('settings.servers.roles.nickColor')}</span>
<span
aria-hidden="true"
data-testid={`role-color-preview-${role.id}`}
className="h-4 w-4 rounded-full border border-border/60"
style={
role.nick_color === undefined
? undefined
: { backgroundColor: roleColorHex(role.nick_color) ?? undefined }
}
/>
<select
value={role.nick_color === undefined ? '' : String(role.nick_color)}
disabled={disabled}
aria-label={t('settings.servers.roles.nickColor')}
data-testid={`role-color-${role.id}`}
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5 text-fg"
onChange={(event) =>
onChange(
event.target.value === ''
? { nick_color: 0xffffff }
: { nick_color: Number(event.target.value) },
)
}
>
<option value="">{t('settings.profile.style.colorDefault')}</option>
{NICK_COLOR_PALETTE.map((color) => (
<option key={color} value={String(color)}>
{roleColorHex(color) ?? ''}
</option>
))}
</select>
</label>
<DecorationSelect
label={t('settings.servers.roles.nickEffect')}
testId={`role-effect-${role.id}`}
value={role.nick_effect ?? 'none'}
disabled={disabled}
options={NICK_EFFECTS.map((effect) => ({
id: effect,
label: t(`settings.profile.style.effects.${effect}`),
}))}
onChange={(value) => onChange({ nick_effect: value as NickEffect })}
/>
</div>
);
}
interface DecorationSelectProps {
label: string;
testId: string;
value: string;
disabled: boolean;
options: { id: string; label: string }[];
emptyLabel?: string;
onChange: (value: string) => void;
}
function DecorationSelect({
label,
testId,
value,
disabled,
options,
emptyLabel,
onChange,
}: DecorationSelectProps) {
return (
<label className="flex items-center gap-1 text-xs">
<span className="text-fg-muted">{label}</span>
<select
value={value}
disabled={disabled}
aria-label={label}
data-testid={testId}
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-1 py-0.5 text-fg"
onChange={(event) => onChange(event.target.value)}
>
{emptyLabel === undefined ? null : <option value="">{emptyLabel}</option>}
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
);
}
+20 -1
View File
@@ -12,12 +12,14 @@ import {
canAdministerGuild,
canBanMembers,
canManageEmojis,
canManageRoles,
canManageSounds,
canViewAuditLog,
} from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection';
import { GuildCosmeticsSection } from '@/pages/settings/GuildCosmeticsSection';
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
@@ -180,6 +182,14 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
currentUser.data?.is_instance_admin === true,
);
// Галерея оформления и рамки/иконки ролей (AGENT.md 7.4) — MANAGE_ROLES.
const canManageRoleSet = canManageRoles(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
const canManageSoundSet = canManageSounds(
guild.my_permissions,
@@ -203,7 +213,16 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
currentUserId={currentUser.data?.id}
/>
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
<GuildCosmeticsSection
key={`${guild.id}-cosmetics`}
guildId={guild.id}
canManage={canManageRoleSet}
/>
<GuildRolesSection
key={`${guild.id}-roles`}
guildId={guild.id}
canManage={canManageRoleSet}
/>
{canManageEmojiSet ? (
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
) : null}
@@ -20,6 +20,7 @@ import { formatBytes } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
import { useSessionStore } from '@/stores/session';
import { ProfileStyleSection } from '@/pages/settings/ProfileStyleSection';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Показываем выбранный файл до ответа сервера. */
@@ -302,8 +303,9 @@ export function ProfileForm({ user }: { user: User }) {
user.badges.map((badge) => <Badge key={badge}>{badge}</Badge>)
)}
</div>
<p className="mt-3 text-sm text-fg-muted">{t('settings.profile.decorations.stub')}</p>
</SettingsSection>
<ProfileStyleSection />
</div>
);
}
@@ -0,0 +1,290 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fileContentUrl } from '@/api/files';
import {
deleteMyStyle,
fetchMyStyles,
myStylesQueryKey,
putMyStyle,
type NickEffect,
type StyleInput,
} from '@/api/cosmetics';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Button } from '@/components/ui/primitives';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { roleColorHex } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Оформление профиля» (AGENT.md 7.2): стиль «для друзей» и пер-серверные
* переопределения. Выбирать можно только то, что выдали роли — списки
* доступных элементов приходят вместе со стилями.
*/
export function ProfileStyleSection() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const [scope, setScope] = useState('global');
const styles = useQuery({
queryKey: myStylesQueryKey,
queryFn: ({ signal }) => fetchMyStyles(signal),
retry: 0,
});
const active = useMemo(
() => styles.data?.find((item) => item.scope === scope) ?? styles.data?.[0] ?? null,
[styles.data, scope],
);
const save = useMutation({
mutationFn: (input: StyleInput) => putMyStyle(active?.scope ?? scope, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: myStylesQueryKey });
},
});
const reset = useMutation({
mutationFn: () => deleteMyStyle(active?.scope ?? scope),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: myStylesQueryKey });
},
});
const pending = save.isPending || reset.isPending;
const style = active?.style ?? {};
/** Меняем один элемент, остальные переносим из текущего стиля. */
const apply = (patch: StyleInput): void => {
const next: StyleInput = {
nick_effect: (style.nick_effect as NickEffect | undefined) ?? 'none',
};
// Пустая строка означает «снять элемент»: сервер сбрасывает его.
if (style.frame_id !== undefined && style.frame_id !== '') {
next.frame_id = style.frame_id;
}
if (style.badge_id !== undefined && style.badge_id !== '') {
next.badge_id = style.badge_id;
}
if (style.nick_color !== undefined) {
next.nick_color = style.nick_color;
}
save.mutate({ ...next, ...patch });
};
const previewName = currentUser.data?.display_name ?? currentUser.data?.username ?? '';
return (
<SettingsSection
title={t('settings.profile.style.title')}
description={t('settings.profile.style.description')}
>
<div className="mt-4 flex flex-col gap-4" data-testid="profile-style">
{styles.isPending ? <SkeletonLines rows={3} /> : null}
{styles.isError ? (
<ErrorNotice error={styles.error} onRetry={() => void styles.refetch()} />
) : null}
{active === null ? null : (
<>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('settings.profile.style.scope')}</span>
<select
value={active.scope}
data-testid="style-scope"
onChange={(event) => setScope(event.target.value)}
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
>
{(styles.data ?? []).map((item) => (
<option key={item.scope} value={item.scope}>
{item.scope === 'global'
? t('settings.profile.style.scopeGlobal')
: (item.guild_name ?? item.scope)}
</option>
))}
</select>
</label>
{/* Предпросмотр: так оформление выглядит в ленте и списках. */}
<div className="flex items-center gap-3 rounded-[var(--radius-md)] border border-border/50 bg-surface-2 p-3">
<FramedAvatar
name={previewName}
seed={currentUser.data?.id ?? 'me'}
fileId={currentUser.data?.avatar_file_id}
size="md"
cosmetics={style}
/>
<Nickname name={previewName} cosmetics={style} className="text-sm" />
<span className="ml-auto text-xs text-fg-muted">
{t('settings.profile.style.preview')}
</span>
</div>
<StylePicker
label={t('settings.profile.style.frame')}
empty={active.frame_ids.length === 0}
emptyHint={t('settings.profile.style.noFrames')}
options={active.frame_ids.map((id) => ({ id, url: fileContentUrl(id) }))}
selected={style.frame_id}
disabled={pending}
onPick={(id) => apply({ frame_id: id ?? '' })}
testIdPrefix="style-frame"
/>
<StylePicker
label={t('settings.profile.style.badge')}
empty={active.badge_ids.length === 0}
emptyHint={t('settings.profile.style.noBadges')}
options={active.badge_ids.map((id) => ({ id, url: fileContentUrl(id) }))}
selected={style.badge_id}
disabled={pending}
onPick={(id) => apply({ badge_id: id ?? '' })}
testIdPrefix="style-badge"
/>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{t('settings.profile.style.color')}</span>
<div className="flex flex-wrap items-center gap-2" data-testid="style-colors">
<button
type="button"
disabled={pending}
aria-pressed={style.nick_color === undefined}
data-testid="style-color-none"
className={`h-7 rounded-[var(--radius-sm)] border px-2 text-xs transition-colors duration-[var(--duration-fast)] ${
style.nick_color === undefined
? 'border-accent text-fg'
: 'border-border/60 text-fg-muted hover:text-fg'
}`}
onClick={() => apply({ nick_color: 0xffffff })}
>
{t('settings.profile.style.colorDefault')}
</button>
{active.nick_colors.map((color) => {
const hex = roleColorHex(color) ?? '#ffffff';
return (
<button
key={color}
type="button"
disabled={pending}
aria-label={hex}
aria-pressed={style.nick_color === color}
data-testid={`style-color-${hex.slice(1)}`}
className={`h-7 w-7 rounded-full border-2 transition-transform duration-[var(--duration-fast)] hover:scale-110 ${
style.nick_color === color ? 'border-accent' : 'border-transparent'
}`}
style={{ backgroundColor: hex }}
onClick={() => apply({ nick_color: color })}
/>
);
})}
</div>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('settings.profile.style.effect')}</span>
<select
value={style.nick_effect ?? 'none'}
data-testid="style-effect"
disabled={pending}
onChange={(event) => apply({ nick_effect: event.target.value as NickEffect })}
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-fg"
>
{active.effects.map((effect) => (
<option key={effect} value={effect}>
{t(`settings.profile.style.effects.${effect}`)}
</option>
))}
</select>
</label>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{reset.isError ? <ErrorNotice error={reset.error} /> : null}
<div className="flex items-center gap-2">
<Button
variant="ghost"
data-testid="style-reset"
disabled={pending}
onClick={() => reset.mutate()}
>
{t('settings.profile.style.reset')}
</Button>
<span className="text-xs text-fg-muted">
{t('settings.profile.style.priorityHint')}
</span>
</div>
</>
)}
</div>
</SettingsSection>
);
}
interface StylePickerProps {
label: string;
empty: boolean;
emptyHint: string;
options: { id: string; url: string }[];
selected: string | undefined;
disabled: boolean;
onPick: (id: string | null) => void;
testIdPrefix: string;
}
/** Сетка выбора элементов оформления: картинка или «без элемента». */
function StylePicker({
label,
empty,
emptyHint,
options,
selected,
disabled,
onPick,
testIdPrefix,
}: StylePickerProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">{label}</span>
{empty && options.length === 0 ? (
<p className="text-xs text-fg-muted">{emptyHint}</p>
) : (
<div className="flex flex-wrap items-center gap-2" data-testid={testIdPrefix}>
<button
type="button"
disabled={disabled}
aria-pressed={selected === undefined || selected === ''}
data-testid={`${testIdPrefix}-none`}
className={`h-12 rounded-[var(--radius-sm)] border px-2 text-xs transition-colors duration-[var(--duration-fast)] ${
selected === undefined || selected === ''
? 'border-accent text-fg'
: 'border-border/60 text-fg-muted hover:text-fg'
}`}
onClick={() => onPick(null)}
>
{t('settings.profile.style.none')}
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
disabled={disabled}
aria-pressed={selected === option.id}
data-testid={`${testIdPrefix}-${option.id}`}
className={`h-12 w-12 rounded-[var(--radius-sm)] border-2 p-1 transition-transform duration-[var(--duration-fast)] hover:scale-105 ${
selected === option.id ? 'border-accent' : 'border-transparent'
}`}
onClick={() => onPick(option.id)}
>
<img src={option.url} alt="" className="h-full w-full object-contain" />
</button>
))}
</div>
)}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
import { nickColorStyle, nickEffectClass } from '@/components/profile/cosmeticsStyle';
import {
bodyOf,
findRequest,
installFetch,
json,
makeUser,
recordedRequests,
renderApp,
resetStores,
type FetchRoute,
} from './helpers';
/**
* Оформление профиля (AGENT.md 7.2, 7.4): отрисовка рамки, иконки, цвета и
* эффекта ника, редактор личного стиля и галерея оформления сервера.
*/
const user = makeUser({ display_name: 'Аля' });
describe('отрисовка оформления', () => {
it('рисует рамку, иконку, цвет и эффект ника', () => {
render(
<div>
<FramedAvatar
name="Аля"
seed="u-1"
size="md"
cosmetics={{
frame_id: 'f-1',
badge_id: 'b-1',
nick_color: 0xe74c3c,
nick_effect: 'glow',
}}
/>
<Nickname
name="Аля"
cosmetics={{ badge_id: 'b-1', nick_color: 0xe74c3c, nick_effect: 'glow' }}
/>
</div>,
);
const frame = screen.getByTestId('avatar-frame');
expect(frame.querySelector('img')?.getAttribute('src')).toBe('/files/f-1');
expect(screen.getByTestId('nick-badge').getAttribute('src')).toBe('/files/b-1');
const nick = screen.getByTestId('nick-name');
expect(nick).toHaveTextContent('Аля');
expect(nick).toHaveClass('gl-nick', 'gl-nick-glow');
expect(nick).toHaveStyle({ color: '#e74c3c' });
});
it('без оформления показывает обычный аватар и ник', () => {
render(<Nickname name="Боб" />);
expect(screen.queryByTestId('avatar-frame')).toBeNull();
expect(screen.queryByTestId('nick-badge')).toBeNull();
expect(screen.getByTestId('nick-name')).toHaveClass('gl-nick');
expect(screen.getByTestId('nick-name')).not.toHaveClass('gl-nick-glow');
});
it('неизвестный эффект и нулевой цвет игнорируются', () => {
expect(nickEffectClass('unknown')).toBe('gl-nick');
expect(nickEffectClass(undefined)).toBe('gl-nick');
expect(nickEffectClass('fire')).toBe('gl-nick gl-nick-fire');
expect(nickColorStyle(0)).toBeUndefined();
expect(nickColorStyle(undefined)).toBeUndefined();
expect(nickColorStyle(0x2ecc71)).toEqual({ color: '#2ecc71' });
});
});
/** Маршруты окна настроек: профиль, стили и серверы. */
function settingsRoutes(styles: unknown[], cosmetics: unknown[] = []): FetchRoute[] {
return [
{ match: '/api/v1/users/@me/styles', response: () => json({ styles }) },
{
match: '/api/v1/users/@me/guilds',
response: () =>
json({
guilds: [
{
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: ['MANAGE_ROLES'],
},
],
}),
},
{ match: '/api/v1/users/@me', response: () => json({ user }) },
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
{ match: '/api/v1/guilds/g-1/cosmetics', response: () => json({ cosmetics }) },
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
{
match: '/api/v1/guilds/g-1',
response: () =>
json({
guild: {
id: 'g-1',
name: 'Сервер',
owner_id: 'user-1',
is_main: true,
member_count: 1,
my_role_ids: [],
my_permissions: ['MANAGE_ROLES', 'ADMINISTRATOR'],
roles: [],
channels: [],
},
}),
},
];
}
const stylesPayload = [
{
scope: 'global',
style: { frame_id: 'f-1' },
frame_ids: ['f-1', 'f-2'],
badge_ids: ['b-1'],
nick_colors: [0xe74c3c, 0x2ecc71],
effects: ['none', 'glow', 'gradient'],
},
{
scope: 'g-1',
guild_id: 'g-1',
guild_name: 'Сервер',
style: {},
frame_ids: ['f-1'],
badge_ids: [],
nick_colors: [0xe74c3c],
effects: ['none', 'glow'],
},
];
beforeEach(() => {
resetStores();
});
describe('редактор личного стиля', () => {
it('меняет рамку, иконку, цвет и эффект и сбрасывает стиль', async () => {
const fetchMock = installFetch(settingsRoutes(stylesPayload));
renderApp('/app');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
await userEvent.click(await screen.findByRole('button', { name: 'Профиль' }));
const section = await screen.findByTestId('profile-style');
// Текущий стиль подставляется в предпросмотр.
expect(within(section).getByTestId('avatar-frame')).toBeInTheDocument();
// Рамка: выбор другой рамки уходит на сервер.
await userEvent.click(within(section).getByTestId('style-frame-f-2'));
await waitFor(() => {
expect(
bodyOf(findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'PUT' })),
).toMatchObject({ frame_id: 'f-2', nick_effect: 'none' });
});
// Иконка.
await userEvent.click(within(section).getByTestId('style-badge-b-1'));
await waitFor(() => {
const calls = recordedRequests(fetchMock).filter(
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
);
expect(calls.at(-1)?.body).toMatchObject({ badge_id: 'b-1' });
});
// Цвет из палитры.
await userEvent.click(within(section).getByTestId('style-color-2ecc71'));
await waitFor(() => {
const calls = recordedRequests(fetchMock).filter(
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
);
expect(calls.at(-1)?.body).toMatchObject({ nick_color: 0x2ecc71 });
});
// Эффект ника.
await userEvent.selectOptions(within(section).getByTestId('style-effect'), 'glow');
await waitFor(() => {
const calls = recordedRequests(fetchMock).filter(
(request) => request.method === 'PUT' && request.url.includes('/styles/'),
);
expect(calls.at(-1)?.body).toMatchObject({ nick_effect: 'glow' });
});
// Сброс стиля.
await userEvent.click(within(section).getByTestId('style-reset'));
await waitFor(() => {
expect(
findRequest(fetchMock, { url: '/users/@me/styles/global', method: 'DELETE' }),
).toBeDefined();
});
});
it('переключает область на пер-серверный стиль', async () => {
installFetch(settingsRoutes(stylesPayload));
renderApp('/app');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
await userEvent.click(await screen.findByRole('button', { name: 'Профиль' }));
const section = await screen.findByTestId('profile-style');
expect(within(section).getByText('Для друзей (DM и глобальные места)')).toBeInTheDocument();
await userEvent.selectOptions(within(section).getByTestId('style-scope'), 'g-1');
// В пер-серверном стиле доступна только одна рамка и нет иконок.
expect(within(section).getByTestId('style-frame-f-1')).toBeInTheDocument();
expect(within(section).queryByTestId('style-frame-f-2')).toBeNull();
expect(within(section).getByText(/Роли пока не выдали ни одной иконки/u)).toBeInTheDocument();
});
});
describe('галерея оформления сервера', () => {
it('загружает элемент, переименовывает и удаляет его', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const cosmetic = {
id: 'c-1',
guild_id: 'g-1',
kind: 'frame',
name: 'Золотая',
file_id: 'file-1',
created_at: '2026-09-20T10:00:00Z',
};
const routes: FetchRoute[] = [
...settingsRoutes([], [cosmetic]),
{
match: '/api/v1/guilds/g-1/cosmetics',
method: 'POST',
response: () => json({ cosmetic }),
},
{
match: '/api/v1/guilds/g-1/cosmetics/c-1',
method: 'PATCH',
response: () => json({ cosmetic: { ...cosmetic, name: 'Новая' } }),
},
{
match: '/api/v1/guilds/g-1/cosmetics/c-1',
method: 'DELETE',
response: () => json({ ok: true }),
},
];
const fetchMock = installFetch(routes);
renderApp('/app');
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
await userEvent.click(await screen.findByRole('button', { name: 'Сервер' }));
const section = await screen.findByTestId('guild-cosmetics');
// Загрузка файла: multipart-запрос с видом элемента.
const file = new File(['картинка'], 'frame.png', { type: 'image/png' });
await userEvent.upload(within(section).getByTestId('cosmetic-input-frame'), file);
await waitFor(() => {
expect(
recordedRequests(fetchMock).some(
(request) => request.method === 'POST' && request.url.includes('/cosmetics'),
),
).toBe(true);
});
// Переименование.
await userEvent.click(within(section).getByTestId('cosmetic-rename-c-1'));
const nameInput = within(section).getByLabelText('Имя элемента');
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Новая');
await userEvent.click(within(section).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => {
expect(
bodyOf(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'PATCH' })),
).toMatchObject({
name: 'Новая',
});
});
// Удаление.
await userEvent.click(within(section).getByTestId('cosmetic-delete-c-1'));
await waitFor(() => {
expect(findRequest(fetchMock, { url: '/cosmetics/c-1', method: 'DELETE' })).toBeDefined();
});
});
});