feat(web): оформление сервера в настройках, рейке и приглашении
Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета. - секция «Оформление» в настройках сервера: загрузка и удаление иконки, баннера и splash с превью, проверка типа и размера на клиенте, пресеты акцентного цвета; - иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла нет), акцентный цвет переопределяет `--color-accent` внутри приложения и снимается при значении 0; - предпросмотр приглашения показывает splash фоном и иконку сервера; - ответ сервера обновляет карточку и список серверов — оформление видно без перезагрузки; - тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.
This commit is contained in:
@@ -96,6 +96,47 @@ export async function updateGuildMember(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Виды оформления сервера (AGENT.md 7.4). */
|
||||||
|
export type GuildAppearanceKind = 'icon' | 'banner' | 'splash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка оформления сервера: multipart с полем `file`. Сервер принимает
|
||||||
|
* только изображения и отвечает обновлённым сервером.
|
||||||
|
*/
|
||||||
|
export async function uploadGuildAppearance(
|
||||||
|
guildId: string,
|
||||||
|
kind: GuildAppearanceKind,
|
||||||
|
file: File,
|
||||||
|
): Promise<Guild> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const payload = await request<{ guild: Guild }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/appearance/${kind}`,
|
||||||
|
{ method: 'POST', form },
|
||||||
|
);
|
||||||
|
return payload.guild;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGuildAppearance(
|
||||||
|
guildId: string,
|
||||||
|
kind: GuildAppearanceKind,
|
||||||
|
): Promise<Guild> {
|
||||||
|
const payload = await request<{ guild: Guild }>(
|
||||||
|
`/guilds/${encodeURIComponent(guildId)}/appearance/${kind}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
return payload.guild;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Акцентный цвет сервера: 0 возвращает цвет темы. */
|
||||||
|
export async function updateGuildAccent(guildId: string, accentColor: number): Promise<Guild> {
|
||||||
|
const payload = await request<{ guild: Guild }>(`/guilds/${encodeURIComponent(guildId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { accent_color: accentColor },
|
||||||
|
});
|
||||||
|
return payload.guild;
|
||||||
|
}
|
||||||
|
|
||||||
/** Удаление сервера: доступно владельцу и администратору инстанса. */
|
/** Удаление сервера: доступно владельцу и администратору инстанса. */
|
||||||
export async function deleteGuild(guildId: string): Promise<void> {
|
export async function deleteGuild(guildId: string): Promise<void> {
|
||||||
await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' });
|
await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' });
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export interface InviteGuildPreview {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon_file_id?: string;
|
icon_file_id?: string;
|
||||||
|
/** Оформление сервера для страницы приглашения (AGENT.md 7.4). */
|
||||||
|
banner_file_id?: string;
|
||||||
|
splash_file_id?: string;
|
||||||
|
accent_color?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
is_member: boolean;
|
is_member: boolean;
|
||||||
@@ -84,6 +88,18 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise<I
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const icon = asString(guild['icon_file_id']);
|
const icon = asString(guild['icon_file_id']);
|
||||||
|
const banner = asString(guild['banner_file_id']);
|
||||||
|
const splash = asString(guild['splash_file_id']);
|
||||||
|
const accent = typeof guild['accent_color'] === 'number' ? guild['accent_color'] : undefined;
|
||||||
|
if (banner !== undefined) {
|
||||||
|
preview.guild.banner_file_id = banner;
|
||||||
|
}
|
||||||
|
if (splash !== undefined) {
|
||||||
|
preview.guild.splash_file_id = splash;
|
||||||
|
}
|
||||||
|
if (accent !== undefined && accent !== 0) {
|
||||||
|
preview.guild.accent_color = accent;
|
||||||
|
}
|
||||||
const description = asString(guild['description']);
|
const description = asString(guild['description']);
|
||||||
if (icon !== undefined) {
|
if (icon !== undefined) {
|
||||||
preview.guild.icon_file_id = icon;
|
preview.guild.icon_file_id = icon;
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ export interface Guild {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon_file_id?: string;
|
icon_file_id?: string;
|
||||||
|
banner_file_id?: string;
|
||||||
|
splash_file_id?: string;
|
||||||
|
/** Акцентный цвет сервера, 0 — цвет темы. */
|
||||||
|
accent_color?: number;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
is_main: boolean;
|
is_main: boolean;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -105,6 +109,9 @@ export interface GuildSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon_file_id?: string;
|
icon_file_id?: string;
|
||||||
|
banner_file_id?: string;
|
||||||
|
splash_file_id?: string;
|
||||||
|
accent_color?: number;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
is_main: boolean;
|
is_main: boolean;
|
||||||
member_count: number;
|
member_count: number;
|
||||||
|
|||||||
@@ -106,8 +106,15 @@ export function InviteJoin({ className = '' }: InviteJoinProps) {
|
|||||||
|
|
||||||
{preview.data === undefined ? null : (
|
{preview.data === undefined ? null : (
|
||||||
<div
|
<div
|
||||||
className="mt-3 flex items-center gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2"
|
className="mt-3 flex items-center gap-3 rounded-[var(--radius-md)] border border-border/60 bg-surface-2 bg-cover bg-center px-3 py-2"
|
||||||
data-testid="invite-preview"
|
data-testid="invite-preview"
|
||||||
|
style={
|
||||||
|
preview.data.guild.splash_file_id === undefined
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
backgroundImage: `url(/files/${encodeURIComponent(preview.data.guild.splash_file_id)})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={preview.data.guild.name}
|
name={preview.data.guild.name}
|
||||||
|
|||||||
@@ -950,6 +950,29 @@
|
|||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"empty": "No entries."
|
"empty": "No entries."
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"description": "Server icon, banner, invite splash and accent color.",
|
||||||
|
"icon": "Server icon",
|
||||||
|
"iconHint": "Square image shown in the server list.",
|
||||||
|
"banner": "Banner",
|
||||||
|
"bannerHint": "Wide image for the server header.",
|
||||||
|
"splash": "Invite splash",
|
||||||
|
"splashHint": "Background of the invite page.",
|
||||||
|
"accent": "Accent color",
|
||||||
|
"accentHint": "Color of buttons and highlights inside the server.",
|
||||||
|
"accentDefault": "Theme color",
|
||||||
|
"accentBlurple": "Blue",
|
||||||
|
"accentGreen": "Green",
|
||||||
|
"accentOrange": "Orange",
|
||||||
|
"accentRed": "Red",
|
||||||
|
"accentPurple": "Purple",
|
||||||
|
"upload": "Upload",
|
||||||
|
"remove": "Remove",
|
||||||
|
"empty": "none",
|
||||||
|
"notImage": "An image file is required (PNG, JPEG, GIF, WebP).",
|
||||||
|
"tooLarge": "The file is larger than 8 MB."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navLabel": "Settings sections",
|
"navLabel": "Settings sections",
|
||||||
|
|||||||
@@ -950,6 +950,29 @@
|
|||||||
"apply": "Показать",
|
"apply": "Показать",
|
||||||
"reset": "Сбросить",
|
"reset": "Сбросить",
|
||||||
"empty": "Записей нет."
|
"empty": "Записей нет."
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Оформление",
|
||||||
|
"description": "Иконка, баннер, splash страницы приглашения и акцентный цвет сервера.",
|
||||||
|
"icon": "Иконка сервера",
|
||||||
|
"iconHint": "Квадратная картинка: показывается в списке серверов.",
|
||||||
|
"banner": "Баннер",
|
||||||
|
"bannerHint": "Широкая картинка для шапки сервера.",
|
||||||
|
"splash": "Splash приглашения",
|
||||||
|
"splashHint": "Фон страницы приглашения по ссылке.",
|
||||||
|
"accent": "Акцентный цвет",
|
||||||
|
"accentHint": "Цвет кнопок и выделений внутри сервера.",
|
||||||
|
"accentDefault": "Цвет темы",
|
||||||
|
"accentBlurple": "Синий",
|
||||||
|
"accentGreen": "Зелёный",
|
||||||
|
"accentOrange": "Оранжевый",
|
||||||
|
"accentRed": "Красный",
|
||||||
|
"accentPurple": "Фиолетовый",
|
||||||
|
"upload": "Загрузить",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"empty": "нет",
|
||||||
|
"notImage": "Нужен файл изображения (PNG, JPEG, GIF, WebP).",
|
||||||
|
"tooLarge": "Файл больше 8 МБ."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"navLabel": "Пункты настроек",
|
"navLabel": "Пункты настроек",
|
||||||
|
|||||||
@@ -141,12 +141,32 @@ export default function AppLayout() {
|
|||||||
}, [fallbackGuild.data, upsertGuild]);
|
}, [fallbackGuild.data, upsertGuild]);
|
||||||
|
|
||||||
const guildList = useMemo(
|
const guildList = useMemo(
|
||||||
() => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
|
() =>
|
||||||
|
guilds.map((guild) => ({
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
|
||||||
|
})),
|
||||||
[guilds],
|
[guilds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canCreate = instance.data?.allow_guild_creation !== false;
|
const canCreate = instance.data?.allow_guild_creation !== false;
|
||||||
|
|
||||||
|
// Акцент сервера переопределяет цвет темы внутри приложения (AGENT.md 7.4).
|
||||||
|
// Значение 0 означает «цвет темы», тогда переменная снимается.
|
||||||
|
const accent = guilds.find((item) => item.id === guildId)?.accent_color ?? 0;
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (accent === 0) {
|
||||||
|
root.style.removeProperty('--color-accent');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.style.setProperty('--color-accent', `#${accent.toString(16).padStart(6, '0')}`);
|
||||||
|
return () => {
|
||||||
|
root.style.removeProperty('--color-accent');
|
||||||
|
};
|
||||||
|
}, [accent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-full flex-col">
|
<div className="flex h-full min-h-full flex-col">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NavLink } from 'react-router';
|
|||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
|
||||||
interface GuildRailProps {
|
interface GuildRailProps {
|
||||||
guilds: { id: string; name: string }[];
|
guilds: { id: string; name: string; icon_file_id?: string | undefined }[];
|
||||||
selectedGuildId: string | null;
|
selectedGuildId: string | null;
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
onCreate: () => void;
|
onCreate: () => void;
|
||||||
@@ -70,7 +70,13 @@ export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: Guil
|
|||||||
: 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0'
|
: 'hover:-translate-y-0.5 hover:rounded-[var(--radius-lg)] active:translate-y-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar name={guild.name} seed={guild.id} size="lg" shape="square" />
|
<Avatar
|
||||||
|
name={guild.name}
|
||||||
|
seed={guild.id}
|
||||||
|
fileId={guild.icon_file_id}
|
||||||
|
size="lg"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/lib/identity';
|
} from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection';
|
||||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||||
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
|
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
|
||||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||||
@@ -190,6 +191,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||||
|
<GuildAppearanceSection key={`${guild.id}-appearance`} guild={guild} canEdit={canManage} />
|
||||||
<GuildMembersSection
|
<GuildMembersSection
|
||||||
guildId={guild.id}
|
guildId={guild.id}
|
||||||
members={members.data}
|
members={members.data}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
bodyOf,
|
||||||
|
installFetch,
|
||||||
|
requestUrl,
|
||||||
|
json,
|
||||||
|
makeUser,
|
||||||
|
recordedRequests,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оформление сервера в настройках (AGENT.md 7.4): иконка, баннер, splash и
|
||||||
|
* акцентный цвет. Загрузка идёт multipart-запросом, сервер отвечает сервером —
|
||||||
|
* карточка и список серверов обновляются без перезагрузки.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
function guildDetail(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'g-1',
|
||||||
|
name: 'Сервер',
|
||||||
|
description: '',
|
||||||
|
owner_id: 'user-1',
|
||||||
|
is_main: false,
|
||||||
|
member_count: 1,
|
||||||
|
roles: [],
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: ['MANAGE_GUILD'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Маршруты оболочки и карточки сервера; оформление отвечает тем же сервером. */
|
||||||
|
function routes(extra: FetchRoute[] = [], guild = guildDetail()): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
...extra,
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/appearance/',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ guild: guildDetail({ icon_file_id: '900' }) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1/appearance/',
|
||||||
|
method: 'DELETE',
|
||||||
|
response: () => json({ guild: guildDetail() }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-1',
|
||||||
|
method: 'PATCH',
|
||||||
|
response: () => json({ guild: guildDetail({ accent_color: 0x5865f2 }) }),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [guild] }) },
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1', response: () => json({ guild }) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAppearance(guild = guildDetail()) {
|
||||||
|
const fetchMock = installFetch(routes([], guild));
|
||||||
|
renderApp('/settings/servers/g-1');
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
const section = await within(dialog).findByTestId('guild-appearance');
|
||||||
|
return { dialog, section, fetchMock };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Файл изображения нужного размера. */
|
||||||
|
function imageFile(name = 'icon.png', type = 'image/png', size = 1024): File {
|
||||||
|
const file = new File(['картинка'], name, { type });
|
||||||
|
Object.defineProperty(file, 'size', { value: size });
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
// jsdom не умеет object URL: подменяем, иначе превью выбранного файла падает.
|
||||||
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview');
|
||||||
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('настройки сервера: оформление', () => {
|
||||||
|
it('показывает иконку, баннер и splash с превью', async () => {
|
||||||
|
const { section } = await openAppearance(
|
||||||
|
guildDetail({
|
||||||
|
icon_file_id: '900',
|
||||||
|
banner_file_id: '901',
|
||||||
|
splash_file_id: '902',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(section).getByTestId('appearance-icon-preview').querySelector('img'),
|
||||||
|
).toHaveAttribute('src', '/files/900');
|
||||||
|
expect(
|
||||||
|
within(section).getByTestId('appearance-banner-preview').querySelector('img'),
|
||||||
|
).toHaveAttribute('src', '/files/901');
|
||||||
|
expect(
|
||||||
|
within(section).getByTestId('appearance-splash-preview').querySelector('img'),
|
||||||
|
).toHaveAttribute('src', '/files/902');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('загружает выбранный файл multipart-запросом', async () => {
|
||||||
|
const { section, fetchMock } = await openAppearance();
|
||||||
|
|
||||||
|
const input = within(section).getByTestId('appearance-icon-input');
|
||||||
|
const file = imageFile();
|
||||||
|
await userEvent.upload(input, file);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const call = fetchMock.mock.calls
|
||||||
|
.filter(
|
||||||
|
([input, init]) =>
|
||||||
|
requestUrl(input).includes('/appearance/icon') &&
|
||||||
|
(init?.method ?? 'GET').toUpperCase() === 'POST',
|
||||||
|
)
|
||||||
|
.at(-1);
|
||||||
|
expect(call).toBeDefined();
|
||||||
|
// Тело multipart не разбирается тестовым помощником: смотрим FormData.
|
||||||
|
const body = call?.[1]?.body;
|
||||||
|
expect(body).toBeInstanceOf(FormData);
|
||||||
|
expect((body as FormData).get('file')).toBe(file);
|
||||||
|
});
|
||||||
|
// Превью обновляется из ответа сервера.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
within(section).getByTestId('appearance-icon-preview').querySelector('img'),
|
||||||
|
).toHaveAttribute('src', '/files/900');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('удаляет оформление и очищает превью', async () => {
|
||||||
|
const { section, fetchMock } = await openAppearance(guildDetail({ banner_file_id: '901' }));
|
||||||
|
|
||||||
|
await userEvent.click(within(section).getByTestId('appearance-banner-remove'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const request = recordedRequests(fetchMock)
|
||||||
|
.filter((item) => item.url.includes('/appearance/banner'))
|
||||||
|
.at(-1);
|
||||||
|
expect(request?.method).toBe('DELETE');
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
within(section).getByTestId('appearance-banner-preview').querySelector('img'),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('применяет акцентный цвет и снимает его', async () => {
|
||||||
|
const { section, fetchMock } = await openAppearance();
|
||||||
|
|
||||||
|
await userEvent.click(within(section).getByTestId(`appearance-accent-${0x5865f2}`));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const request = recordedRequests(fetchMock)
|
||||||
|
.filter((item) => item.method === 'PATCH' && item.url.endsWith('/guilds/g-1'))
|
||||||
|
.at(-1);
|
||||||
|
expect(bodyOf(request)).toEqual({ accent_color: 0x5865f2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(within(section).getByTestId('appearance-accent-0'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const request = recordedRequests(fetchMock)
|
||||||
|
.filter((item) => item.method === 'PATCH' && item.url.endsWith('/guilds/g-1'))
|
||||||
|
.at(-1);
|
||||||
|
expect(bodyOf(request)).toEqual({ accent_color: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('не отправляет файл, который не является изображением', async () => {
|
||||||
|
const { section, fetchMock } = await openAppearance();
|
||||||
|
|
||||||
|
// applyAccept: false — проверяем серверную проверку типа, а не атрибут accept.
|
||||||
|
await userEvent.upload(
|
||||||
|
within(section).getByTestId('appearance-icon-input'),
|
||||||
|
new File(['текст'], 'note.txt', { type: 'text/plain' }),
|
||||||
|
{ applyAccept: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await within(section).findByTestId('appearance-error')).toHaveTextContent(
|
||||||
|
'Нужен файл изображения',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
recordedRequests(fetchMock).filter((item) => item.url.includes('/appearance/icon')),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('скрывает секцию без права MANAGE_GUILD', async () => {
|
||||||
|
const guild = guildDetail({ owner_id: 'user-9', my_permissions: [] });
|
||||||
|
installFetch(routes([], guild));
|
||||||
|
renderApp('/settings/servers/g-1');
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
|
||||||
|
await within(dialog).findByText('Общее');
|
||||||
|
expect(within(dialog).queryByTestId('guild-appearance')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user