fix(install): публичный адрес LiveKit вычисляется после TLS_ENABLED
Установщик падал на 'TLS_ENABLED: unbound variable': расчёт LIVEKIT_PUBLIC_URL стоял выше определения TLS_ENABLED. Значение перенесено после блока TLS, dry-run проходит.
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
import { useRef, useState, type DragEvent } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
EMOJI_LIMIT_PER_GUILD,
|
||||
EMOJI_MAX_BYTES,
|
||||
deleteGuildEmoji,
|
||||
fetchGuildEmojis,
|
||||
guildEmojisQueryKey,
|
||||
isAllowedEmojiType,
|
||||
isValidEmojiName,
|
||||
renameGuildEmoji,
|
||||
uploadGuildEmoji,
|
||||
type GuildEmoji,
|
||||
} from '@/api/emojis';
|
||||
import { fileContentUrl } from '@/api/files';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Field } from '@/components/ui/Field';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Badge, Button } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { customEmojiShortcode } from '@/lib/customEmoji';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
interface GuildEmojisSectionProps {
|
||||
guildId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Локальная ошибка проверки файла: текст показываем сразу, на сервер не ходим.
|
||||
* Ключ перевода — как у серверных ошибок (`errors.<code>`), кроме имени.
|
||||
*/
|
||||
type LocalError = { kind: 'file.too_large' | 'emoji.invalid_type' | 'emoji.invalid_name' };
|
||||
|
||||
/**
|
||||
* «Эмодзи»: загрузка, переименование и удаление кастомных эмодзи сервера
|
||||
* (AGENT.md 7.12, право MANAGE_EMOJIS).
|
||||
*
|
||||
* Список читается по REST (`GET /guilds/{id}/emojis`), а после операций
|
||||
* инвалидируется: снапшот обновляет и событие шлюза `GUILD_EMOJIS_UPDATE`,
|
||||
* и точечные правки в сторе сессии — лента и пикер видят эмодзи сразу.
|
||||
*/
|
||||
export function GuildEmojisSection({ guildId }: GuildEmojisSectionProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [localError, setLocalError] = useState<LocalError | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [renaming, setRenaming] = useState<GuildEmoji | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [removing, setRemoving] = useState<GuildEmoji | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: guildEmojisQueryKey(guildId),
|
||||
queryFn: ({ signal }) => fetchGuildEmojis(guildId, signal),
|
||||
retry: 0,
|
||||
});
|
||||
|
||||
const emojis = list.data ?? [];
|
||||
const full = emojis.length >= EMOJI_LIMIT_PER_GUILD;
|
||||
|
||||
/** Инвалидация кэша: снапшот придёт событием шлюза либо правкой стора. */
|
||||
const refresh = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: guildEmojisQueryKey(guildId) });
|
||||
};
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: (input: { name: string; file: File }) =>
|
||||
uploadGuildEmoji(guildId, input.name, input.file),
|
||||
onSuccess: (emoji) => {
|
||||
if (emoji !== null) {
|
||||
useSessionStore.getState().upsertGuildEmoji(guildId, emoji);
|
||||
}
|
||||
setName('');
|
||||
setFile(null);
|
||||
setLocalError(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const rename = useMutation({
|
||||
mutationFn: (input: { emojiId: string; name: string }) =>
|
||||
renameGuildEmoji(guildId, input.emojiId, input.name),
|
||||
onSuccess: (emoji) => {
|
||||
if (emoji !== null) {
|
||||
useSessionStore.getState().upsertGuildEmoji(guildId, emoji);
|
||||
}
|
||||
setRenaming(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (emojiId: string) => deleteGuildEmoji(guildId, emojiId),
|
||||
onSuccess: (_result, emojiId) => {
|
||||
useSessionStore.getState().removeGuildEmoji(guildId, emojiId);
|
||||
setRemoving(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
/** Проверки на клиенте: формат, размер и имя — до запроса на сервер. */
|
||||
const pickFile = (next: File | undefined): void => {
|
||||
if (next === undefined) {
|
||||
return;
|
||||
}
|
||||
upload.reset();
|
||||
if (!isAllowedEmojiType(next)) {
|
||||
setFile(null);
|
||||
setLocalError({ kind: 'emoji.invalid_type' });
|
||||
return;
|
||||
}
|
||||
if (next.size > EMOJI_MAX_BYTES) {
|
||||
setFile(null);
|
||||
setLocalError({ kind: 'file.too_large' });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setFile(next);
|
||||
};
|
||||
|
||||
const submit = (): void => {
|
||||
if (file === null) {
|
||||
setLocalError({ kind: 'emoji.invalid_type' });
|
||||
return;
|
||||
}
|
||||
if (!isValidEmojiName(name)) {
|
||||
setLocalError({ kind: 'emoji.invalid_name' });
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
upload.mutate({ name, file });
|
||||
};
|
||||
|
||||
const onDrop = (event: DragEvent<HTMLDivElement>): void => {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
pickFile(event.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.servers.emojis.title')}
|
||||
description={t('settings.servers.emojis.description', { limit: EMOJI_LIMIT_PER_GUILD })}
|
||||
>
|
||||
<p className="mt-3 text-sm text-fg-muted" data-testid="guild-emojis-counter">
|
||||
{t('settings.servers.emojis.counter', {
|
||||
count: emojis.length,
|
||||
limit: EMOJI_LIMIT_PER_GUILD,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{list.isPending ? (
|
||||
<SkeletonLines className="mt-4" rows={3} />
|
||||
) : list.isError ? (
|
||||
<ErrorNotice className="mt-4" error={list.error} onRetry={() => void list.refetch()} />
|
||||
) : emojis.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.emojis.empty')}</p>
|
||||
) : (
|
||||
<ul className="gl-stagger mt-4 flex flex-col gap-1" data-testid="guild-emojis">
|
||||
{emojis.map((emoji) => (
|
||||
<li
|
||||
key={emoji.id}
|
||||
data-testid={`guild-emoji-${emoji.id}`}
|
||||
className="flex items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
|
||||
>
|
||||
<img
|
||||
src={fileContentUrl(emoji.file_id)}
|
||||
alt={customEmojiShortcode(emoji.name)}
|
||||
loading="lazy"
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{customEmojiShortcode(emoji.name)}
|
||||
</span>
|
||||
{emoji.size_bytes === undefined ? null : (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{formatBytes(emoji.size_bytes, i18n.language)}
|
||||
</span>
|
||||
)}
|
||||
{emoji.animated ? (
|
||||
<Badge>{t('settings.servers.emojis.animated')}</Badge>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label={t('settings.servers.emojis.renameLabel', {
|
||||
name: customEmojiShortcode(emoji.name),
|
||||
})}
|
||||
onClick={() => {
|
||||
rename.reset();
|
||||
setRenameValue(emoji.name);
|
||||
setRenaming(emoji);
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.emojis.rename')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
aria-label={t('settings.servers.emojis.deleteLabel', {
|
||||
name: customEmojiShortcode(emoji.name),
|
||||
})}
|
||||
onClick={() => {
|
||||
remove.reset();
|
||||
setRemoving(emoji);
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.emojis.delete')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{remove.isError ? <ErrorNotice className="mt-3" error={remove.error} /> : null}
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 border-t border-border/40 pt-4">
|
||||
<h3 className="text-sm font-semibold">{t('settings.servers.emojis.uploadTitle')}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t('settings.servers.emojis.uploadHint', {
|
||||
limit: formatBytes(EMOJI_MAX_BYTES, i18n.language),
|
||||
})}
|
||||
</p>
|
||||
{full ? <p className="text-xs text-warning">{t('errors.emojis.limit_reached')}</p> : null}
|
||||
|
||||
<Field
|
||||
label={t('settings.servers.emojis.name')}
|
||||
name="emoji_name"
|
||||
value={name}
|
||||
maxLength={32}
|
||||
placeholder={t('settings.servers.emojis.namePlaceholder')}
|
||||
hint={t('settings.servers.emojis.nameHint')}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
|
||||
<div
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-[var(--radius-md)] border border-dashed px-3 py-2 text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
dragging ? 'border-accent bg-accent/10' : 'border-border/60 bg-surface-2/40'
|
||||
}`}
|
||||
data-testid="emoji-dropzone"
|
||||
>
|
||||
<span className="text-fg-muted">{t('settings.servers.emojis.dropHint')}</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
aria-label={t('settings.servers.emojis.file')}
|
||||
onChange={(event) => {
|
||||
pickFile(event.target.files?.[0]);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button variant="ghost" onClick={() => fileInputRef.current?.click()}>
|
||||
{t('settings.servers.emojis.chooseFile')}
|
||||
</Button>
|
||||
{file === null ? null : (
|
||||
<span className="text-xs text-fg-muted">
|
||||
{t('settings.servers.emojis.fileSelected', {
|
||||
name: file.name,
|
||||
size: formatBytes(file.size, i18n.language),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{localError === null ? null : (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{t(`errors.${localError.kind}`)}
|
||||
</p>
|
||||
)}
|
||||
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
disabled={upload.isPending || file === null || name.trim() === '' || full}
|
||||
onClick={submit}
|
||||
>
|
||||
{t(upload.isPending ? 'settings.servers.emojis.uploading' : 'settings.servers.emojis.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={renaming !== null}
|
||||
title={t('settings.servers.emojis.renameTitle')}
|
||||
onClose={() => setRenaming(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRenaming(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={rename.isPending || !isValidEmojiName(renameValue.trim())}
|
||||
onClick={() => {
|
||||
if (renaming !== null) {
|
||||
rename.mutate({ emojiId: renaming.id, name: renameValue.trim() });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(rename.isPending ? 'common.saving' : 'common.save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
label={t('settings.servers.emojis.name')}
|
||||
name="emoji_rename"
|
||||
value={renameValue}
|
||||
maxLength={32}
|
||||
hint={t('settings.servers.emojis.nameHint')}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
/>
|
||||
{rename.isError ? <ErrorNotice className="mt-3" error={rename.error} /> : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={removing !== null}
|
||||
title={t('settings.servers.emojis.deleteConfirmTitle')}
|
||||
onClose={() => setRemoving(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRemoving(null)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="border-danger/50 text-danger"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (removing !== null) {
|
||||
remove.mutate(removing.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('settings.servers.emojis.deleteConfirm')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg-muted">
|
||||
{t('settings.servers.emojis.deleteConfirmBody', {
|
||||
name: removing === null ? '' : customEmojiShortcode(removing.name),
|
||||
})}
|
||||
</p>
|
||||
</Modal>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Card } from '@/components/ui/primitives';
|
||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||
import { canAdministerGuild } from '@/lib/identity';
|
||||
import { canAdministerGuild, canManageEmojis } from '@/lib/identity';
|
||||
import { useCurrentUser } from '@/lib/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
|
||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||
@@ -18,7 +19,7 @@ import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||
|
||||
/**
|
||||
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||
* общее (имя и описание), участники, роли и «Опасная зона».
|
||||
* общее (имя и описание), участники, роли, эмодзи и «Опасная зона».
|
||||
*
|
||||
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
|
||||
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
|
||||
@@ -140,6 +141,15 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
// «Эмодзи» сервер разрешает владельцу, админу инстанса, ADMINISTRATOR
|
||||
// и MANAGE_EMOJIS: остальным секция не показывается.
|
||||
const canManageEmojiSet = canManageEmojis(
|
||||
guild.my_permissions,
|
||||
guild.owner_id,
|
||||
currentUser.data?.id,
|
||||
currentUser.data?.is_instance_admin === true,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||
@@ -150,6 +160,9 @@ function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||
onRetry={() => void members.refetch()}
|
||||
/>
|
||||
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
||||
{canManageEmojiSet ? (
|
||||
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
|
||||
) : null}
|
||||
<GuildDangerSection
|
||||
key={`${guild.id}-danger`}
|
||||
guildId={guild.id}
|
||||
|
||||
Reference in New Issue
Block a user