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

309 lines
11 KiB
TypeScript
Raw Normal View History

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { 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 { useApiErrorMessage } from '@/lib/useApiErrorMessage';
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);
const applyGuild = (updated: Guild): void => {
// Карточка сервера и его список в рейке обновляются сразу: оформление
// видно без перезагрузки (AGENT.md 11.6).
queryClient.setQueryData(guildQueryKey(guild.id), updated);
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);
setPreviews((current) => ({ ...current, [variables.kind]: undefined }));
applyGuild(updated);
},
onError: (cause) => setError(cause),
});
const remove = useMutation({
mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind),
onSuccess: (updated) => {
setError(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;
}
setPreviews((current) => ({ ...current, [kind]: URL.createObjectURL(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>
);
}