From 6882516340891ff00bd788c91094028d4da35b8a Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 23:34:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(web):=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B2=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B0=D1=85,=20=D1=80=D0=B5=D0=B9=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета. - секция «Оформление» в настройках сервера: загрузка и удаление иконки, баннера и splash с превью, проверка типа и размера на клиенте, пресеты акцентного цвета; - иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла нет), акцентный цвет переопределяет `--color-accent` внутри приложения и снимается при значении 0; - предпросмотр приглашения показывает splash фоном и иконку сервера; - ответ сервера обновляет карточку и список серверов — оформление видно без перезагрузки; - тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD. --- web/src/api/guilds.ts | 41 +++ web/src/api/invites.ts | 16 + web/src/api/types.ts | 7 + web/src/components/friends/InviteJoin.tsx | 9 +- web/src/i18n/locales/en.json | 23 ++ web/src/i18n/locales/ru.json | 23 ++ web/src/pages/app/AppLayout.tsx | 22 +- web/src/pages/app/GuildRail.tsx | 10 +- .../pages/settings/GuildAppearanceSection.tsx | 308 ++++++++++++++++++ web/src/pages/settings/GuildSettingsPage.tsx | 2 + web/tests/guildAppearance.test.tsx | 206 ++++++++++++ 11 files changed, 663 insertions(+), 4 deletions(-) create mode 100644 web/src/pages/settings/GuildAppearanceSection.tsx create mode 100644 web/tests/guildAppearance.test.tsx diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts index 309c0e7..d5293e4 100644 --- a/web/src/api/guilds.ts +++ b/web/src/api/guilds.ts @@ -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 { + 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 { + 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 { + 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 { await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' }); diff --git a/web/src/api/invites.ts b/web/src/api/invites.ts index 7260bfd..028e4d0 100644 --- a/web/src/api/invites.ts +++ b/web/src/api/invites.ts @@ -12,6 +12,10 @@ export interface InviteGuildPreview { id: string; name: string; icon_file_id?: string; + /** Оформление сервера для страницы приглашения (AGENT.md 7.4). */ + banner_file_id?: string; + splash_file_id?: string; + accent_color?: number; description?: string; member_count: number; is_member: boolean; @@ -84,6 +88,18 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise 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], ); 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 (
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' }`} > - + ); diff --git a/web/src/pages/settings/GuildAppearanceSection.tsx b/web/src/pages/settings/GuildAppearanceSection.tsx new file mode 100644 index 0000000..60fea94 --- /dev/null +++ b/web/src/pages/settings/GuildAppearanceSection.tsx @@ -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>>({}); + const [error, setError] = useState(null); + // Три отдельных ref: правило react-hooks/refs запрещает читать их из объекта + // во время рендера. + const iconInput = useRef(null); + const bannerInput = useRef(null); + const splashInput = useRef(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 ( + +
+ iconInput.current?.click()} + onRemove={() => remove.mutate('icon')} + /> + { + pick('icon', event.target.files?.[0]); + event.target.value = ''; + }} + /> + + bannerInput.current?.click()} + onRemove={() => remove.mutate('banner')} + /> + { + pick('banner', event.target.files?.[0]); + event.target.value = ''; + }} + /> + + splashInput.current?.click()} + onRemove={() => remove.mutate('splash')} + /> + { + pick('splash', event.target.files?.[0]); + event.target.value = ''; + }} + /> + +
+

{t('settings.servers.appearance.accent')}

+

{t('settings.servers.appearance.accentHint')}

+
+ {ACCENT_PRESETS.map((preset) => { + const selected = (guild.accent_color ?? 0) === preset.value; + return ( +
+
+ + {error === null || error === undefined ? null : ( +

+ {error instanceof Error ? error.message : describeError(error)} +

+ )} +
+
+ ); +} + +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 ( +
+ {shape === 'wide' ? ( + + {src === undefined ? ( + t('settings.servers.appearance.empty') + ) : ( + + )} + + ) : ( + + + + )} + + {label} + {hint} + + + +
+ ); +} diff --git a/web/src/pages/settings/GuildSettingsPage.tsx b/web/src/pages/settings/GuildSettingsPage.tsx index 298e2de..c453a49 100644 --- a/web/src/pages/settings/GuildSettingsPage.tsx +++ b/web/src/pages/settings/GuildSettingsPage.tsx @@ -17,6 +17,7 @@ import { } from '@/lib/identity'; import { useCurrentUser } from '@/lib/hooks'; import { useSessionStore } from '@/stores/session'; +import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection'; import { GuildDangerSection } from '@/pages/settings/GuildDangerSection'; import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection'; import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection'; @@ -190,6 +191,7 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) { return ( <> + = {}) { + 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(); + }); +});