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