4ea2965892
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера показывает баннер сразу после подключения (AGENT.md 7.4) - GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки - кнопка приглашения в шапке сервера со ссылкой и копированием - встроенные звуки интерфейса на Web Audio с разблокировкой по жесту - object URL превью через безопасные обёртки (jsdom и урезанные webview)
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
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<Partial<Record<GuildAppearanceKind, string>>>({});
|
||
const [error, setError] = useState<unknown>(null);
|
||
// Три отдельных ref: правило react-hooks/refs запрещает читать их из объекта
|
||
// во время рендера.
|
||
const iconInput = useRef<HTMLInputElement | null>(null);
|
||
const bannerInput = useRef<HTMLInputElement | null>(null);
|
||
const splashInput = useRef<HTMLInputElement | null>(null);
|
||
// Зеркало превью: нужно, чтобы освобождать object URL при закрытии настроек.
|
||
const previewsRef = useRef<Partial<Record<GuildAppearanceKind, string>>>({});
|
||
|
||
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 (
|
||
<SettingsSection
|
||
title={t('settings.servers.appearance.title')}
|
||
description={t('settings.servers.appearance.description')}
|
||
>
|
||
<div className="mt-4 flex flex-col gap-4" data-testid="guild-appearance">
|
||
<AppearanceRow
|
||
testId="appearance-icon"
|
||
label={t('settings.servers.appearance.icon')}
|
||
hint={t('settings.servers.appearance.iconHint')}
|
||
fileId={guild.icon_file_id}
|
||
preview={previews.icon}
|
||
name={guild.name}
|
||
seed={guild.id}
|
||
shape="square"
|
||
disabled={pending}
|
||
onPick={() => iconInput.current?.click()}
|
||
onRemove={() => remove.mutate('icon')}
|
||
/>
|
||
<input
|
||
ref={iconInput}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
data-testid="appearance-icon-input"
|
||
onChange={(event) => {
|
||
pick('icon', event.target.files?.[0]);
|
||
event.target.value = '';
|
||
}}
|
||
/>
|
||
|
||
<AppearanceRow
|
||
testId="appearance-banner"
|
||
label={t('settings.servers.appearance.banner')}
|
||
hint={t('settings.servers.appearance.bannerHint')}
|
||
fileId={guild.banner_file_id}
|
||
preview={previews.banner}
|
||
name={guild.name}
|
||
seed={`${guild.id}-banner`}
|
||
shape="wide"
|
||
disabled={pending}
|
||
onPick={() => bannerInput.current?.click()}
|
||
onRemove={() => remove.mutate('banner')}
|
||
/>
|
||
<input
|
||
ref={bannerInput}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
data-testid="appearance-banner-input"
|
||
onChange={(event) => {
|
||
pick('banner', event.target.files?.[0]);
|
||
event.target.value = '';
|
||
}}
|
||
/>
|
||
|
||
<AppearanceRow
|
||
testId="appearance-splash"
|
||
label={t('settings.servers.appearance.splash')}
|
||
hint={t('settings.servers.appearance.splashHint')}
|
||
fileId={guild.splash_file_id}
|
||
preview={previews.splash}
|
||
name={guild.name}
|
||
seed={`${guild.id}-splash`}
|
||
shape="wide"
|
||
disabled={pending}
|
||
onPick={() => splashInput.current?.click()}
|
||
onRemove={() => remove.mutate('splash')}
|
||
/>
|
||
<input
|
||
ref={splashInput}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
data-testid="appearance-splash-input"
|
||
onChange={(event) => {
|
||
pick('splash', event.target.files?.[0]);
|
||
event.target.value = '';
|
||
}}
|
||
/>
|
||
|
||
<div>
|
||
<p className="text-sm font-medium">{t('settings.servers.appearance.accent')}</p>
|
||
<p className="text-xs text-fg-muted">{t('settings.servers.appearance.accentHint')}</p>
|
||
<div className="mt-2 flex flex-wrap items-center gap-2" data-testid="appearance-accent">
|
||
{ACCENT_PRESETS.map((preset) => {
|
||
const selected = (guild.accent_color ?? 0) === preset.value;
|
||
return (
|
||
<button
|
||
key={preset.labelKey}
|
||
type="button"
|
||
disabled={pending}
|
||
aria-pressed={selected}
|
||
aria-label={t(preset.labelKey)}
|
||
title={t(preset.labelKey)}
|
||
data-testid={`appearance-accent-${preset.value}`}
|
||
onClick={() => accent.mutate(preset.value)}
|
||
className={`h-8 w-8 rounded-full border transition-transform duration-[var(--duration-fast)] active:scale-95 ${
|
||
selected ? 'border-fg ring-2 ring-accent' : 'border-border/60'
|
||
}`}
|
||
style={
|
||
preset.value === 0
|
||
? { background: 'var(--color-accent)' }
|
||
: { background: `#${preset.value.toString(16).padStart(6, '0')}` }
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{error === null || error === undefined ? null : (
|
||
<p className="text-sm text-danger" role="alert" data-testid="appearance-error">
|
||
{error instanceof Error ? error.message : describeError(error)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</SettingsSection>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="flex flex-wrap items-center gap-3" data-testid={testId}>
|
||
{shape === 'wide' ? (
|
||
<span
|
||
className="flex h-16 w-40 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border/60 bg-surface-2 text-xs text-fg-muted"
|
||
data-testid={`${testId}-preview`}
|
||
>
|
||
{src === undefined ? (
|
||
t('settings.servers.appearance.empty')
|
||
) : (
|
||
<img src={src} alt="" className="h-full w-full object-cover" />
|
||
)}
|
||
</span>
|
||
) : (
|
||
<span data-testid={`${testId}-preview`}>
|
||
<Avatar
|
||
name={name}
|
||
seed={seed}
|
||
fileId={fileId}
|
||
preview={preview}
|
||
size="lg"
|
||
shape="square"
|
||
/>
|
||
</span>
|
||
)}
|
||
<span className="min-w-0 flex-1">
|
||
<span className="block text-sm font-medium">{label}</span>
|
||
<span className="block text-xs text-fg-muted">{hint}</span>
|
||
</span>
|
||
<Button variant="ghost" disabled={disabled} onClick={onPick} data-testid={`${testId}-upload`}>
|
||
{t('settings.servers.appearance.upload')}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
disabled={disabled || src === undefined}
|
||
onClick={onRemove}
|
||
data-testid={`${testId}-remove`}
|
||
>
|
||
{t('settings.servers.appearance.remove')}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|