import { useRef, useState, type DragEvent } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { SOUND_EVENTS, SOUND_LIMIT_PER_KIND, SOUND_MAX_BYTES, deleteGuildSound, fetchGuildSounds, guildSoundsQueryKey, isAllowedSoundType, isValidSoundName, renameGuildSound, uploadGuildSound, type GuildSound, type SoundEvent, type SoundKind, } from '@/api/sounds'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; import { Field } from '@/components/ui/Field'; import { Modal } from '@/components/ui/Modal'; import { Badge, Button } from '@/components/ui/primitives'; import { SkeletonLines } from '@/components/ui/Skeleton'; import { formatBytes } from '@/lib/format'; import { useSessionStore } from '@/stores/session'; import { SettingsSection } from '@/pages/settings/SettingsSection'; interface GuildSoundsSectionProps { guildId: string; } /** * Локальная ошибка проверки: текст показываем сразу, на сервер не ходим. * Ключ перевода — как у серверных ошибок (`errors.`). */ type LocalError = | { kind: 'file.too_large' } | { kind: 'sound.invalid_type' } | { kind: 'sound.invalid_name' } | { kind: 'sounds.event_required' }; /** Названия событий палитры: подписи берём из локалей. */ function eventLabelKey(event: SoundEvent): string { return `settings.servers.sounds.events.${event}`; } /** * «Звуки»: саундборд и звуковая палитра сервера (AGENT.md 7.13, право * MANAGE_SOUNDS). Список читается по REST, а снапшот обновляется точечно — * и загрузкой, и событием шлюза `GUILD_SOUNDS_UPDATE`. */ export function GuildSoundsSection({ guildId }: GuildSoundsSectionProps) { const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); const [kind, setKind] = useState('soundboard'); const [name, setName] = useState(''); const [emoji, setEmoji] = useState(''); const [event, setEvent] = useState('member_join'); const [file, setFile] = useState(null); const [localError, setLocalError] = useState(null); const [dragging, setDragging] = useState(false); const [renaming, setRenaming] = useState(null); const [renameValue, setRenameValue] = useState(''); const [removing, setRemoving] = useState(null); const fileInputRef = useRef(null); const list = useQuery({ queryKey: guildSoundsQueryKey(guildId), queryFn: ({ signal }) => fetchGuildSounds(guildId, undefined, signal), retry: 0, }); const sounds = list.data ?? []; const soundboard = sounds.filter((sound) => sound.kind === 'soundboard'); const palette = sounds.filter((sound) => sound.kind === 'ui'); const current = kind === 'soundboard' ? soundboard : palette; const full = current.length >= SOUND_LIMIT_PER_KIND; /** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */ const refresh = (): void => { void queryClient.invalidateQueries({ queryKey: guildSoundsQueryKey(guildId) }); }; const upload = useMutation({ mutationFn: (input: { name: string; file: File; kind: SoundKind; event?: SoundEvent; emoji?: string; }) => uploadGuildSound(guildId, input.kind, input), onSuccess: (sound) => { if (sound !== null) { useSessionStore.getState().upsertGuildSound(guildId, sound); } setName(''); setEmoji(''); setFile(null); setLocalError(null); refresh(); }, }); const rename = useMutation({ mutationFn: (input: { soundId: string; name: string; emoji?: string }) => renameGuildSound(guildId, input.soundId, { name: input.name, ...(input.emoji === undefined || input.emoji === '' ? {} : { emoji: input.emoji }), }), onSuccess: (sound) => { if (sound !== null) { useSessionStore.getState().upsertGuildSound(guildId, sound); } setRenaming(null); refresh(); }, }); const remove = useMutation({ mutationFn: (soundId: string) => deleteGuildSound(guildId, soundId), onSuccess: (_result, soundId) => { useSessionStore.getState().removeGuildSound(guildId, soundId); setRemoving(null); refresh(); }, }); /** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */ const pickFile = (next: File | undefined): void => { if (next === undefined) { return; } upload.reset(); if (!isAllowedSoundType(next)) { setFile(null); setLocalError({ kind: 'sound.invalid_type' }); return; } if (next.size > SOUND_MAX_BYTES) { setFile(null); setLocalError({ kind: 'file.too_large' }); return; } setLocalError(null); setFile(next); }; const submit = (): void => { if (file === null) { setLocalError({ kind: 'sound.invalid_type' }); return; } if (!isValidSoundName(name)) { setLocalError({ kind: 'sound.invalid_name' }); return; } setLocalError(null); upload.mutate({ name, file, kind, ...(kind === 'ui' ? { event } : {}), ...(emoji.trim() === '' ? {} : { emoji: emoji.trim() }), }); }; const onDrop = (event_: DragEvent): void => { event_.preventDefault(); setDragging(false); pickFile(event_.dataTransfer.files[0]); }; /** Строка списка: имя, событие, эмодзи, переименование и удаление. */ const renderRow = (sound: GuildSound) => (
  • {sound.name} {sound.event === undefined ? null : {t(eventLabelKey(sound.event))}}
  • ); return (
    {(['soundboard', 'ui'] as SoundKind[]).map((item) => ( ))}

    {t('settings.servers.sounds.counter', { count: current.length, limit: SOUND_LIMIT_PER_KIND, })}

    {list.isPending ? ( ) : list.isError ? ( void list.refetch()} /> ) : current.length === 0 ? (

    {t('settings.servers.sounds.empty')}

    ) : (
      {current.map(renderRow)}
    )} {remove.isError ? : null}

    {t('settings.servers.sounds.uploadTitle')}

    {t('settings.servers.sounds.uploadHint', { limit: formatBytes(SOUND_MAX_BYTES, i18n.language), })}

    {full ?

    {t('errors.sounds.limit_reached')}

    : null} setName(event_.target.value)} /> setEmoji(event_.target.value)} /> {kind !== 'ui' ? null : ( )}
    { event_.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={onDrop} className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${ dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40' }`} data-testid="sound-dropzone" > {t('settings.servers.sounds.dropHint')} { pickFile(event_.target.files?.[0]); event_.target.value = ''; }} /> {file === null ? null : ( {t('settings.servers.sounds.fileSelected', { name: file.name, size: formatBytes(file.size, i18n.language), })} )}
    {localError === null ? null : (

    {t(`errors.${localError.kind}`)}

    )} {upload.isError ? : null}
    setRenaming(null)} footer={ <> } > setRenameValue(event_.target.value)} /> {rename.isError ? : null} setRemoving(null)} footer={ <> } >

    {t('settings.servers.sounds.deleteConfirmBody', { name: removing?.name ?? '' })}

    ); }