Files
glchat/web/src/pages/settings/GuildCosmeticsSection.tsx
T

232 lines
8.3 KiB
TypeScript
Raw Normal View History

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>
);
}