Files
glchat/web/src/pages/settings/GuildAppearanceSection.tsx
T
grendervill 4ea2965892 fix(web): баннер сервера из снапшота, приглашение, встроенные звуки
- READY-снапшот больше не теряет banner/splash/accent: шапка сервера
  показывает баннер сразу после подключения (AGENT.md 7.4)
- GUILD_UPDATE несёт всё оформление, клиент применяет его без перезагрузки
- кнопка приглашения в шапке сервера со ссылкой и копированием
- встроенные звуки интерфейса на Web Audio с разблокировкой по жесту
- object URL превью через безопасные обёртки (jsdom и урезанные webview)
2026-09-21 00:04:49 +03:00

344 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}