2026-09-20 23:34:05 +03:00
|
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
2026-09-21 00:04:49 +03:00
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2026-09-20 23:34:05 +03:00
|
|
|
|
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';
|
2026-09-21 00:04:49 +03:00
|
|
|
|
import { revokeObjectUrl, safeObjectUrl } from '@/lib/objectUrl';
|
2026-09-20 23:34:05 +03:00
|
|
|
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
2026-09-21 00:04:49 +03:00
|
|
|
|
import { useSessionStore } from '@/stores/session';
|
2026-09-20 23:34:05 +03:00
|
|
|
|
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);
|
2026-09-21 00:04:49 +03:00
|
|
|
|
// Зеркало превью: нужно, чтобы освобождать 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);
|
|
|
|
|
|
};
|
2026-09-20 23:34:05 +03:00
|
|
|
|
|
|
|
|
|
|
const applyGuild = (updated: Guild): void => {
|
2026-09-21 00:04:49 +03:00
|
|
|
|
// Карточка сервера, рейка и шапка обновляются сразу: рейка берёт иконку из
|
|
|
|
|
|
// session-store, а не из запроса, поэтому обновляем оба места (AGENT.md 11.6).
|
2026-09-20 23:34:05 +03:00
|
|
|
|
queryClient.setQueryData(guildQueryKey(guild.id), updated);
|
2026-09-21 00:04:49 +03:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-09-20 23:34:05 +03:00
|
|
|
|
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);
|
2026-09-21 00:04:49 +03:00
|
|
|
|
// Превью заменяет ответ сервера: локальная ссылка больше не нужна.
|
|
|
|
|
|
putPreview(variables.kind, null);
|
2026-09-20 23:34:05 +03:00
|
|
|
|
applyGuild(updated);
|
|
|
|
|
|
},
|
|
|
|
|
|
onError: (cause) => setError(cause),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const remove = useMutation({
|
|
|
|
|
|
mutationFn: (kind: GuildAppearanceKind) => deleteGuildAppearance(guild.id, kind),
|
2026-09-21 00:04:49 +03:00
|
|
|
|
onSuccess: (updated, kind) => {
|
2026-09-20 23:34:05 +03:00
|
|
|
|
setError(null);
|
2026-09-21 00:04:49 +03:00
|
|
|
|
putPreview(kind, null);
|
2026-09-20 23:34:05 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-21 00:04:49 +03:00
|
|
|
|
putPreview(kind, safeObjectUrl(file));
|
2026-09-20 23:34:05 +03:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|