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> {
|
||||
await request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}`, { method: 'DELETE' });
|
||||
|
||||
@@ -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<I
|
||||
},
|
||||
};
|
||||
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']);
|
||||
if (icon !== undefined) {
|
||||
preview.guild.icon_file_id = icon;
|
||||
|
||||
@@ -91,6 +91,10 @@ export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
banner_file_id?: string;
|
||||
splash_file_id?: string;
|
||||
/** Акцентный цвет сервера, 0 — цвет темы. */
|
||||
accent_color?: number;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
description: string;
|
||||
@@ -105,6 +109,9 @@ export interface GuildSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
icon_file_id?: string;
|
||||
banner_file_id?: string;
|
||||
splash_file_id?: string;
|
||||
accent_color?: number;
|
||||
owner_id: string;
|
||||
is_main: boolean;
|
||||
member_count: number;
|
||||
|
||||
@@ -106,8 +106,15 @@ export function InviteJoin({ className = '' }: InviteJoinProps) {
|
||||
|
||||
{preview.data === undefined ? null : (
|
||||
<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"
|
||||
style={
|
||||
preview.data.guild.splash_file_id === undefined
|
||||
? undefined
|
||||
: {
|
||||
backgroundImage: `url(/files/${encodeURIComponent(preview.data.guild.splash_file_id)})`,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
name={preview.data.guild.name}
|
||||
|
||||
@@ -950,6 +950,29 @@
|
||||
"apply": "Apply",
|
||||
"reset": "Reset",
|
||||
"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",
|
||||
|
||||
@@ -950,6 +950,29 @@
|
||||
"apply": "Показать",
|
||||
"reset": "Сбросить",
|
||||
"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": "Пункты настроек",
|
||||
|
||||
@@ -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