feat(web): оформление сервера в настройках, рейке и приглашении
Фаза 4 (AGENT.md 7.4): интерфейс для иконки, баннера, splash и акцентного цвета. - секция «Оформление» в настройках сервера: загрузка и удаление иконки, баннера и splash с превью, проверка типа и размера на клиенте, пресеты акцентного цвета; - иконка сервера показывается в рейке (заглушка-буквы остаётся, если файла нет), акцентный цвет переопределяет `--color-accent` внутри приложения и снимается при значении 0; - предпросмотр приглашения показывает splash фоном и иконку сервера; - ответ сервера обновляет карточку и список серверов — оформление видно без перезагрузки; - тесты: превью всех трёх видов, multipart-загрузка, удаление, применение и снятие акцента, отказ для не-изображения, скрытие секции без MANAGE_GUILD.
This commit is contained in:
@@ -141,12 +141,32 @@ export default function AppLayout() {
|
||||
}, [fallbackGuild.data, upsertGuild]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex h-full min-h-full flex-col">
|
||||
<a
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NavLink } from 'react-router';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
interface GuildRailProps {
|
||||
guilds: { id: string; name: string }[];
|
||||
guilds: { id: string; name: string; icon_file_id?: string | undefined }[];
|
||||
selectedGuildId: string | null;
|
||||
canCreate: boolean;
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</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';
|
||||
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 (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
<GuildAppearanceSection key={`${guild.id}-appearance`} guild={guild} canEdit={canManage} />
|
||||
<GuildMembersSection
|
||||
guildId={guild.id}
|
||||
members={members.data}
|
||||
|
||||
Reference in New Issue
Block a user