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