import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { deleteGuildAppearance, guildQueryKey, updateGuildAccent, uploadGuildAppearance, type GuildAppearanceKind, } from '@/api/guilds'; import { myGuildsQueryKey } from '@/api/users'; import { Avatar } from '@/components/ui/Avatar'; import { Button } from '@/components/ui/primitives'; import { avatarUrl } from '@/lib/identity'; import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl'; import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; import { useSessionStore } from '@/stores/session'; import { SettingsSection } from '@/pages/settings/SettingsSection'; import type { Guild } from '@/api/types'; /** Пресеты акцентного цвета: 0 — цвет темы. */ const ACCENT_PRESETS: { value: number; labelKey: string }[] = [ { value: 0, labelKey: 'settings.servers.appearance.accentDefault' }, { value: 0x5865f2, labelKey: 'settings.servers.appearance.accentBlurple' }, { value: 0x2ecc71, labelKey: 'settings.servers.appearance.accentGreen' }, { value: 0xe67e22, labelKey: 'settings.servers.appearance.accentOrange' }, { value: 0xe74c3c, labelKey: 'settings.servers.appearance.accentRed' }, { value: 0x9b59b6, labelKey: 'settings.servers.appearance.accentPurple' }, ]; /** Максимальный размер файла оформления: сервер отвечает `file.too_large`. */ export const GUILD_IMAGE_MAX_BYTES = 8 * 1024 * 1024; interface GuildAppearanceSectionProps { guild: Guild; canEdit: boolean; } /** * «Оформление»: иконка, баннер, splash для страницы приглашения и акцентный * цвет сервера (AGENT.md 7.4). Анимированные GIF/APNG/WebP не перекодируются, * поэтому показываются с анимацией как есть. */ export function GuildAppearanceSection({ guild, canEdit }: GuildAppearanceSectionProps) { const { t } = useTranslation(); const describeError = useApiErrorMessage(); const queryClient = useQueryClient(); const [previews, setPreviews] = useState>>({}); const [error, setError] = useState(null); // Три отдельных ref: правило react-hooks/refs запрещает читать их из объекта // во время рендера. const iconInput = useRef(null); const bannerInput = useRef(null); const splashInput = useRef(null); // Зеркало превью: нужно, чтобы освобождать object URL при закрытии настроек. const previewsRef = useRef>>({}); useEffect( () => () => { for (const url of Object.values(previewsRef.current)) { revokeObjectUrl(url); } previewsRef.current = {}; }, [], ); /** Меняет превью вида, освобождая предыдущую ссылку на локальный файл. */ const putPreview = (kind: GuildAppearanceKind, url: string | null): void => { revokeObjectUrl(previewsRef.current[kind]); const next = { ...previewsRef.current }; if (url === null) { delete next[kind]; } else { next[kind] = url; } previewsRef.current = next; setPreviews(next); }; const applyGuild = (updated: Guild): void => { // Карточка сервера, рейка и шапка обновляются сразу: рейка берёт иконку из // session-store, а не из запроса, поэтому обновляем оба места (AGENT.md 11.6). queryClient.setQueryData(guildQueryKey(guild.id), updated); useSessionStore.getState().applyGuildAppearance(guild.id, { icon_file_id: updated.icon_file_id ?? '', banner_file_id: updated.banner_file_id ?? '', splash_file_id: updated.splash_file_id ?? '', accent_color: updated.accent_color ?? 0, }); void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey }); }; const upload = useMutation({ mutationFn: ({ kind, file }: { kind: GuildAppearanceKind; file: File }) => uploadGuildAppearance(guild.id, kind, file), onSuccess: (updated, variables) => { setError(null); // Превью заменяет ответ сервера: локальная ссылка больше не нужна. putPreview(variables.kind, null); applyGuild(updated); }, onError: (cause) => setError(cause), }); const remove = useMutation({ mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind), onSuccess: (updated, kind) => { setError(null); putPreview(kind, null); applyGuild(updated); }, onError: (cause) => setError(cause), }); const accent = useMutation({ mutationFn: (value: number) => updateGuildAccent(guild.id, value), onSuccess: (updated) => { setError(null); applyGuild(updated); }, onError: (cause) => setError(cause), }); if (!canEdit) { return null; } const pick = (kind: GuildAppearanceKind, file: File | undefined): void => { if (file === undefined) { return; } if (!file.type.startsWith('image/')) { setError(new Error(t('settings.servers.appearance.notImage'))); return; } if (file.size > GUILD_IMAGE_MAX_BYTES) { setError(new Error(t('settings.servers.appearance.tooLarge'))); return; } putPreview(kind, safeObjectUrl(file)); upload.mutate({ kind, file }); }; const pending = upload.isPending || remove.isPending || accent.isPending; return (
iconInput.current?.click()} onRemove={() => remove.mutate('icon')} /> { pick('icon', event.target.files?.[0]); event.target.value = ''; }} /> bannerInput.current?.click()} onRemove={() => remove.mutate('banner')} /> { pick('banner', event.target.files?.[0]); event.target.value = ''; }} /> splashInput.current?.click()} onRemove={() => remove.mutate('splash')} /> { pick('splash', event.target.files?.[0]); event.target.value = ''; }} />

{t('settings.servers.appearance.accent')}

{t('settings.servers.appearance.accentHint')}

{ACCENT_PRESETS.map((preset) => { const selected = (guild.accent_color ?? 0) === preset.value; return (
{error === null || error === undefined ? null : (

{error instanceof Error ? error.message : describeError(error)}

)}
); } interface AppearanceRowProps { testId: string; label: string; hint: string; fileId: string | undefined; preview: string | undefined; name: string; seed: string; shape: 'square' | 'wide'; disabled: boolean; onPick: () => void; onRemove: () => void; } /** AppearanceRow — превью, выбор файла и удаление для одного вида оформления. */ function AppearanceRow({ testId, label, hint, fileId, preview, name, seed, shape, disabled, onPick, onRemove, }: AppearanceRowProps) { const { t } = useTranslation(); const url = avatarUrl(fileId); const src = preview ?? (url === null ? undefined : url); return (
{shape === 'wide' ? ( {src === undefined ? ( t('settings.servers.appearance.empty') ) : ( )} ) : ( )} {label} {hint}
); }