fix(files): аватары и баннеры доступны авторизованным пользователям

Аватар нельзя было показать в списке друзей: файл без комнаты был доступен
только загрузившему. Добавлено назначение файла (`files.purpose`: attachment |
avatar | banner, миграция 00006): аватары и баннеры отдаются любому
авторизованному пользователю, вложения по-прежнему проверяются по правам
комнаты, а непривязанные загрузки остаются приватными.
This commit is contained in:
2026-09-20 01:39:04 +03:00
parent 20eed227ca
commit d02ef7527a
35 changed files with 2330 additions and 191 deletions
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { createGuild } from '@/api/guilds';
import { instanceQueryKey } from '@/api/instance';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { useInstance } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/**
* Единственное модальное окно создания сервера (`POST /guilds {name}`).
* Открывается из рейки и с пустого экрана «У вас пока нет серверов»,
* поэтому смонтировано в `AppLayout`, а состояние — в `ui`-сторе.
*/
export function CreateGuildModal() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const instance = useInstance();
const open = useUiStore((state) => state.guildCreateOpen);
const close = useUiStore((state) => state.closeGuildCreate);
const guilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const [name, setName] = useState('');
const create = useMutation({
mutationFn: (guildName: string) => createGuild(guildName),
onSuccess: (guild) => {
close();
setName('');
upsertGuild(guild);
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
void navigate(`/app/${guild.id}`);
},
});
const limitReached =
instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
return (
<Modal
open={open}
title={t('guilds.create.title')}
onClose={close}
footer={
<>
<Button variant="ghost" onClick={close}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate(name.trim())}
disabled={create.isPending || name.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'guilds.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (name.trim() !== '') {
create.mutate(name.trim());
}
}}
>
<Field
label={t('guilds.create.name')}
placeholder={t('guilds.create.namePlaceholder')}
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={64}
hint={t('guilds.create.hint')}
/>
{instance.data === undefined ? null : (
<p className="text-xs text-fg-muted">
{t('guilds.create.limit', { count: instance.data.max_guilds_per_user })}
</p>
)}
{limitReached && !create.isError ? (
<p className="text-xs text-warning" role="status">
{t('guilds.create.limitReached')}
</p>
) : null}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
);
}