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