LP-5613 #2
260
README.md
@ -1,175 +1,193 @@
|
||||
# Badges plugin
|
||||
Let your users show appreciation for their fellow colleagues by granting badges.
|
||||
# Плагин значков для Loop
|
||||
|
||||
## Install
|
||||
Get the latest release from [GitHub](https://github.com/larkox/mattermost-plugin-badges/releases) and [install it manually](https://developers.mattermost.com/integrate/plugins/server/hello-world/#installing-the-plugin) on your server.
|
||||
Позволяет пользователям выражать признательность коллегам, выдавая им значки.
|
||||
|
||||
## Configuration
|
||||
## Установка
|
||||
|
||||

|
||||
Скачайте последний релиз и установите его вручную через системную консоль Loop.
|
||||
|
||||
- **Badges admin**: Every System Admin is considered a badges admin. System Admins can assign the badges admin role to a single person by specifying their username. Only a single badge admin assignment is permitted.
|
||||
## Настройка
|
||||
|
||||
## Usage
|
||||
### Creating a type
|
||||
Badge admins can create different types of badges, and each type of badge can have its own permissions. You must be a badge admin to create a badge type.
|
||||
Run the slash command `/badges create type` to open the creation dialog.
|
||||
- **Администраторы значков**: все системные администраторы автоматически являются администраторами значков. Дополнительно можно назначить любое количество администраторов значков через мультиселект в настройках плагина.
|
||||
|
||||

|
||||
## Использование
|
||||
|
||||
- **Name**: The type of badge that's visible in the badges description.
|
||||
- **Everyone can create badge**: If you mark this checkbox, every user in your Mattermost instance can create badges of this type.
|
||||
- **Can create allowlist**: This list contains the usernames (comma separated) of all the people allowed to create badges of this type.
|
||||
- **Everyone can grant badge**: If you mark this checkbox, every user in your Mattermost instance can grant any badge of this type.
|
||||
- **Can grant allowlist**: This list contains the usernames (comma separated) of all the people allowed to grant badges of this type.
|
||||
### Создание типа значка
|
||||
|
||||
### Permissions details
|
||||
Badge admins can always create types, create badges for any type, and grant badges from any type, regardless of the permissions in place for a given badge type.
|
||||
A badge creator can always grant the badge they created.
|
||||
Any other user is subject to the permissions defined as part of the badge type.
|
||||
Администраторы значков могут создавать разные типы значков, каждый со своими правами доступа. Для создания типа необходимы права администратора значков.
|
||||
|
||||
Some examples of badge permissions by type are included below. Remember that badge admins have full control over badges, and badge creators can always grant badges. The examples below are intended to demonstrate how badge permissions can be configured for non-admin users to get the most out of badges.
|
||||
(ECC: Everyone Can Create, CC: Can Create Allowlist, ECG: Everyone Can Grant, CG: Can Grant Allowlist)
|
||||
| Permissions | Example | ECC | ECG | CC | CG |
|
||||
|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------|--------------|--------------|
|
||||
| Only badge admins can create and grant badges | | false | false | empty | empty |
|
||||
| Only user1 can create badges, but everyone can grant them | for peer appreciation badges, like "Thank you" badge | false | true | user1 | empty |
|
||||
| Only user1 can create badges, and only user2 and user3 can grant them | lead appreciation badges, like "MVP" badge, where the management create the badges, and the team leads are the ones granting them to their team members | false | false | user1 | user2, user3 |
|
||||
| Only user1 and user2 can create badges, but they can only grant the badges they have created | can be used to have team specific badges without creating a new type for every team | false | false | user1, user2 | empty |
|
||||
| Everyone can create badges, but can only grant the badges they have created | | true | false | empty | empty |
|
||||
| Everyone can create and grant any badge | | true | true | empty | empty |
|
||||
Выполните слэш-команду `/badges create type`, чтобы открыть диалог создания.
|
||||
|
||||
### Creating a badge
|
||||
Run the slash command `/badges create badge` to open the creation dialog.
|
||||
- **Название**: название типа, отображаемое в описании значков.
|
||||
- **Все могут создавать значки**: если отмечено, любой пользователь Loop может создавать значки этого типа.
|
||||
- **Список допущенных к созданию**: username'ы через запятую — кто может создавать значки этого типа.
|
||||
- **Все могут выдавать значки**: если отмечено, любой пользователь Loop может выдавать значки этого типа.
|
||||
- **Список допущенных к выдаче**: username'ы через запятую — кто может выдавать значки этого типа.
|
||||
|
||||

|
||||
### Права доступа
|
||||
|
||||
- **Name**: Name of the badge.
|
||||
- **Description**: Description of the badge.
|
||||
- **Image**: Only emojis are allowed. You must input the emoji name as you would to add it to a message (e.g. `:+1:` or `:smile:`). Custom emojis are also allowed.
|
||||
- **Type**: The type of badge. This list will show only types you have permissions to create.
|
||||
- **Multiple**: Whether this badge can be granted more than once to the same person.
|
||||
Администраторы значков всегда могут создавать типы, создавать значки любого типа и выдавать любые значки — независимо от настроек прав типа. Создатель значка всегда может выдавать созданный им значок. Остальные пользователи подчиняются правам, настроенным для типа.
|
||||
|
||||
### Details about Multiple
|
||||
All badges can be assigned to any number of people. What the **Multiple** setting controls is whether this badge can be granted more than once to the same person. For example, a "Thank you" badge should be grantable many times (many people can be thankful to you on more than one occasion), and therefore, a Thank You badge should have the **Multiple** option selected. However, a "First year in the company" badge should be granted only once since a user won't celebrate this milestone multiple times at the same company. This type of badge should have the **Multiple** option unselected.
|
||||
Примеры конфигураций (ECC: все могут создавать, CC: список допущенных к созданию, ECG: все могут выдавать, CG: список допущенных к выдаче):
|
||||
|
||||
### Granting a badge
|
||||
There are two ways to open the grant dialog:
|
||||
- Run the `/badges grant` command.
|
||||
- Click on the **Grant badge** link available in the Profile Popover, visible when you click on someone's username.
|
||||
| Права | Пример использования | ECC | ECG | CC | CG |
|
||||
|-------|---------------------|-----|-----|----|----|
|
||||
| Только администраторы могут создавать и выдавать | | false | false | пусто | пусто |
|
||||
| Только user1 создаёт, но выдавать может каждый | Значок "Спасибо" от коллег | false | true | user1 | пусто |
|
||||
| Только user1 создаёт, выдают только user2 и user3 | Значок "MVP" — руководство создаёт, тимлиды выдают | false | false | user1 | user2, user3 |
|
||||
| user1 и user2 создают, выдают только свои значки | Командные значки без создания нового типа на каждую команду | false | false | user1, user2 | пусто |
|
||||
| Все создают и выдают только свои значки | | true | false | пусто | пусто |
|
||||
| Все создают и выдают любые значки | | true | true | пусто | пусто |
|
||||
|
||||

|
||||
### Создание значка
|
||||
|
||||
The dialog looks like this:
|
||||
Выполните слэш-команду `/badges create badge`, чтобы открыть диалог создания.
|
||||
|
||||

|
||||
- **Название**: название значка.
|
||||
- **Описание**: описание значка.
|
||||
- **Изображение**: только эмодзи. Укажите имя эмодзи как в сообщении (например `:+1:` или `:smile:`). Кастомные эмодзи тоже поддерживаются.
|
||||
- **Тип**: тип значка. В списке отображаются только типы, для которых у вас есть права на создание.
|
||||
- **Множественный**: можно ли выдавать этот значок одному человеку несколько раз.
|
||||
|
||||
- **User**: The user you want to grant the badge to (may be prepopulated if you clicked the grant button from the profile popover, or added the username in the command).
|
||||
- **Badge**: The badge you want to grant (may be prepopulated if you added the badge id in the command).
|
||||
- **Reason**: An optional reason why you are awarding this badge. (Specially useful for badges like "Thank you").
|
||||
- **Notify on this channel**: If you select this option, a message from the badges bot will be posted in the current channel, letting everyone in that channel know that you granted this badge to that person.
|
||||
The user that received the badge will always receive a DM from the badges bot letting them know they have been awarded a badge. In addition, the following may happen:
|
||||
- If **Notify on this channel** was marked, the badges bot will post a message on the current channel letting everyone know that the user has been awarded a badge.
|
||||
- If a subscription for this badge type is set, the badges bot will post a message on all subscribed channels letting everyone know that the user has been awarded a badge.
|
||||
### Подробнее о параметре "Множественный"
|
||||
|
||||

|
||||
Любой значок можно выдать любому количеству людей. Параметр **Множественный** определяет, можно ли выдать значок одному и тому же человеку более одного раза. Например, значок "Спасибо" стоит сделать множественным — один человек может получить благодарность многократно. А значок "Первый год в компании" должен быть невозможно выдать дважды.
|
||||
|
||||
If you try to award a badge that can't be awarded more than once to a single recipient, the badge won't be granted.
|
||||
### Выдача значка
|
||||
|
||||
### Subscriptions
|
||||
In order to create a subscription, you must be a badges admin.
|
||||
Subscriptions will create posts into a channel every time a badge is granted. There is no limit to the number of subscriptions per channel or per type.
|
||||
There are two ways to open the subscription creation dialog:
|
||||
- Run the `/badges subscription create` command.
|
||||
- Click on the **Add badge subscription** menu from the channel menu.
|
||||
Открыть диалог выдачи можно двумя способами:
|
||||
- Выполнить команду `/badges grant`.
|
||||
- Нажать **Выдать значок** в поповере профиля пользователя.
|
||||
|
||||

|
||||
- **Пользователь**: кому выдаётся значок.
|
||||
- **Значок**: какой значок выдаётся.
|
||||
- **Причина**: необязательное пояснение (особенно полезно для значков типа "Спасибо").
|
||||
- **Уведомить в этом канале**: если отмечено, бот опубликует сообщение в текущем канале о выдаче значка.
|
||||
|
||||
The dialog looks like this:
|
||||
Получатель значка всегда получает личное сообщение от бота. Дополнительно:
|
||||
- Если отмечено **Уведомить в этом канале** — бот публикует сообщение в текущем канале.
|
||||
- Если настроена подписка для этого типа — бот публикует сообщение во всех подписанных каналах.
|
||||
|
||||

|
||||
### Подписки
|
||||
|
||||
- **Type**: The type of badges you want to subscribe to this channel.
|
||||
Подписки позволяют автоматически публиковать сообщение в канале каждый раз, когда выдаётся значок определённого типа. Количество подписок не ограничено.
|
||||
|
||||
In order to remove subscriptions, a similar dialog can be opened by using the `/badges subscription remove` and the **Remove badge subscription** option from the channel menu.
|
||||
Для создания подписки необходимы права администратора значков. Открыть диалог создания подписки можно двумя способами:
|
||||
- Выполнить команду `/badges subscription create`.
|
||||
- Нажать **Добавить подписку на значки** в меню канала.
|
||||
|
||||
### Editing a deleting badges and types
|
||||
In order to edit or delete types you must be a badge admin. In order to edit or delete a badge, you must be a badge admin or the creator.
|
||||
Run `/badges edit type --type typeID` or `/badges edit badge --id badgeID` to open a dialog pretty similar to the creation dialog. IDs are not human readable, but Autocomplete will help you select the right badge.
|
||||
- **Тип**: тип значков, на который подписывается канал.
|
||||
|
||||

|
||||

|
||||
Для удаления подписки используйте `/badges subscription remove` или **Удалить подписку на значки** в меню канала.
|
||||
|
||||
The only difference to the creation is one extra checkbox to remove the current type or badge. If you mark this checkbox and click **Edit**, the badge or type will be removed.
|
||||
When you remove a badge, the badge is deleted permanently, along with any information about who that badge was granted to. When you remove a type, the type and all the associated badges are removed completely.
|
||||
### Редактирование и удаление значков и типов
|
||||
|
||||
### Badge list
|
||||
Badges show on several places. On the profile popover of the users, they show up to the last 20 badges granted to that user. Hovering over the badges will give you more information, and cliking on them will open the Right Hand Sidebar (RHS) with the badge details.
|
||||
Для редактирования и удаления типов необходимы права администратора значков. Для редактирования и удаления значка — права администратора или создателя значка.
|
||||
|
||||

|
||||
Выполните `/badges edit type --type typeID` или `/badges edit badge --id badgeID`, чтобы открыть диалог редактирования. Автодополнение поможет выбрать нужный значок или тип.
|
||||
|
||||
The channel header button will open the RHS with the list of all badges.
|
||||
Диалог редактирования аналогичен диалогу создания, но с дополнительным чекбоксом удаления. Если отметить его и нажать **Изменить** — значок или тип будет удалён.
|
||||
|
||||

|
||||

|
||||
При удалении значка безвозвратно удаляется вся история его выдачи. При удалении типа удаляются тип и все связанные с ним значки.
|
||||
|
||||
Clicking on any badge will lead you to the badge details. Here you can check all the users that have been granted this badge.
|
||||
### Список значков
|
||||
|
||||

|
||||
Значки отображаются в нескольких местах. В поповере профиля — до 20 последних полученных значков. При наведении на значок появляется подсказка, при клике открывается панель с деталями значка.
|
||||
|
||||
Clicking on any username on the badge details screen will lead you to the badges granted to that user.
|
||||
Кнопка в шапке канала открывает панель со списком всех значков. Клик на любой значок показывает его детали и список пользователей, которым он был выдан. Клик на имя пользователя показывает все его значки.
|
||||
|
||||

|
||||
---
|
||||
|
||||
## Using the Plugin API to create and grant badges
|
||||
This plugin can be integrated with any other plugin in your system, to automatize the creation and granting of badges.
|
||||
## REST API
|
||||
|
||||
Using the [PluginHTTP](https://developers.mattermost.com/integrate/plugins/server/reference/#API.PluginHTTP) API method, you can create a request to the badges plugin to "Ensure" and to "Grant" the badges needed.
|
||||
Базовый URL: `https://<сервер>/plugins/ru.loop.plugin.achievements`
|
||||
|
||||
The badges plugin exposes the `badgesmodel` package to simplify handling several parts of this process. Some important exposed objects:
|
||||
- badgesmodel.PluginPath (`/com.mattermost.badges`): The base URL for the plugin (the plugin id).
|
||||
- badgesmodel.PluginAPIPath (`/papi/v1`): The plugin api route.
|
||||
- badgesmodel.PluginAPIPathEnsure (`/ensure`): The ensure endpoint route.
|
||||
- badgesmodel.PluginAPIPathGrant (`/grant`): The grant endpoint route.
|
||||
- badgesmodel.Badge: The data model for badges.
|
||||
- badgesmodel.EnsureBadgesRequest: The data model of the body of a Ensure Badges Request.
|
||||
- badgesmodel.GrantBadgeRequest: The data model of the body of a Grant Badge Request.
|
||||
- badgesmodel.ImageTypeEmoj (`emoji`): The emoji image type. Other image types are considered, but we recommend using emojis.
|
||||
Все запросы требуют токен в заголовке:
|
||||
```
|
||||
Authorization: Bearer <токен>
|
||||
```
|
||||
|
||||
### Ensure badges
|
||||
URL: `/com.mattermost.badges/papi/v1/ensure`
|
||||
Токен можно получить в настройках аккаунта Loop: **Профиль → Безопасность → Персональные токены доступа**.
|
||||
|
||||
Method: `POST`
|
||||
### Типы значков
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/api/v1/getTypes` | Список типов, доступных текущему пользователю |
|
||||
| `POST` | `/api/v1/createType` | Создать тип (только администраторы) |
|
||||
| `PUT` | `/api/v1/updateType` | Обновить тип (только администраторы) |
|
||||
| `DELETE` | `/api/v1/deleteType/{typeID}` | Удалить тип и все его значки (только администраторы) |
|
||||
|
||||
### Значки
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/api/v1/getAllBadges` | Полный список значков |
|
||||
| `GET` | `/api/v1/getUserBadges/{userID}` | Значки конкретного пользователя |
|
||||
| `GET` | `/api/v1/getBadgeDetails/{badgeID}` | Детали значка и список получателей |
|
||||
| `POST` | `/api/v1/createBadge` | Создать значок |
|
||||
| `PUT` | `/api/v1/updateBadge` | Обновить значок |
|
||||
| `DELETE` | `/api/v1/deleteBadge/{badgeID}` | Удалить значок и историю его выдачи |
|
||||
|
||||
### Выдача значков
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `POST` | `/api/v1/grantBadge` | Выдать значок пользователю |
|
||||
| `POST` | `/api/v1/revokeOwnership` | Отозвать выдачу значка |
|
||||
|
||||
### Подписки
|
||||
|
||||
| Метод | Эндпоинт | Описание |
|
||||
|-------|----------|----------|
|
||||
| `GET` | `/api/v1/getChannelSubscriptions/{channelID}` | Список подписок канала |
|
||||
| `POST` | `/api/v1/createSubscription` | Создать подписку (только администраторы) |
|
||||
| `POST` | `/api/v1/deleteSubscription` | Удалить подписку (только администраторы) |
|
||||
|
||||
---
|
||||
|
||||
## Интеграция с другими плагинами (Plugin API)
|
||||
|
||||
Плагин поддерживает интеграцию с другими плагинами Loop через механизм `PluginHTTP`. Авторизация выполняется автоматически ядром Loop — токен не нужен.
|
||||
|
||||
Доступны два эндпоинта:
|
||||
|
||||
### Ensure — создать значки если не существуют
|
||||
|
||||
URL: `/ru.loop.plugin.achievements/papi/v1/ensure`
|
||||
|
||||
Метод: `POST`
|
||||
|
||||
Body example:
|
||||
```json
|
||||
{
|
||||
"Badges":[
|
||||
{
|
||||
"name":"My badge",
|
||||
"description":"Awesome badge",
|
||||
"image":"smile",
|
||||
"image_type":"emoji",
|
||||
"multiple":true
|
||||
}
|
||||
],
|
||||
"BotId":"myBotId"
|
||||
"Badges": [
|
||||
{
|
||||
"name": "My badge",
|
||||
"description": "Awesome badge",
|
||||
"image": "smile",
|
||||
"image_type": "emoji",
|
||||
"multiple": true
|
||||
}
|
||||
],
|
||||
"BotId": "botUserId"
|
||||
}
|
||||
```
|
||||
Ensure badges will create badges if they already do not exist, and return the list of badges including the ids. In order to check whether a badge exist or not, it will only check the name of the badge.
|
||||
|
||||
### Grant badges
|
||||
URL: `/com.mattermost.badges/papi/v1/grant`
|
||||
Создаёт значки, если они ещё не существуют (проверка по имени), и возвращает их с заполненными `id`.
|
||||
|
||||
Method: `POST`
|
||||
### Grant — выдать значок
|
||||
|
||||
URL: `/ru.loop.plugin.achievements/papi/v1/grant`
|
||||
|
||||
Метод: `POST`
|
||||
|
||||
Body example:
|
||||
```json
|
||||
{
|
||||
"BadgeID":"badgeID",
|
||||
"BotId":"myBotId",
|
||||
"UserID":"userID",
|
||||
"Reason":""
|
||||
"BadgeID": "badgeID",
|
||||
"BotId": "botUserId",
|
||||
"UserID": "userID",
|
||||
"Reason": "За крутую работу"
|
||||
}
|
||||
```
|
||||
Grant badges will grant the badge with the badge id provided from the bot to the user defined. Reason is optional.
|
||||
|
||||
Выдаёт значок от имени указанного бота пользователю. `Reason` — необязательное поле.
|
||||
|
||||
@ -3,6 +3,7 @@ package badgesmodel
|
||||
const (
|
||||
NameMaxLength = 20
|
||||
DescriptionMaxLength = 120
|
||||
DefaultTypeName = "Общий"
|
||||
|
||||
ImageTypeEmoji ImageType = "emoji"
|
||||
ImageTypeRelativeURL ImageType = "rel_url"
|
||||
|
||||
@ -2,6 +2,7 @@ package badgesmodel
|
||||
|
||||
import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type BadgeType string
|
||||
@ -56,6 +57,7 @@ type BadgeTypeDefinition struct {
|
||||
CreatedBy string `json:"created_by"`
|
||||
CanGrant PermissionScheme `json:"can_grant"`
|
||||
CanCreate PermissionScheme `json:"can_create"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
|
||||
type PermissionScheme struct {
|
||||
@ -87,8 +89,8 @@ type Subscription struct {
|
||||
}
|
||||
|
||||
func (b Badge) IsValid() bool {
|
||||
return len(b.Name) <= NameMaxLength &&
|
||||
len(b.Description) <= DescriptionMaxLength &&
|
||||
return utf8.RuneCountInString(b.Name) <= NameMaxLength &&
|
||||
utf8.RuneCountInString(b.Description) <= DescriptionMaxLength &&
|
||||
b.Image != ""
|
||||
}
|
||||
|
||||
|
||||
885
server/api.go
@ -271,7 +271,12 @@ func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *m
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditBadge(u, p.badgeAdminUserID, badge) {
|
||||
badgeType, err := p.store.GetType(badge.Type)
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditBadge(u, p.badgeAdminUserIDs, badge, badgeType) {
|
||||
return commandError(T("badges.error.cannot_edit_badge", "У вас нет прав на редактирование этого значка"))
|
||||
}
|
||||
|
||||
@ -359,7 +364,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||
if !canCreateType(u, p.badgeAdminUserIDs, false) {
|
||||
return commandError(T("badges.error.no_permissions_edit_type", "У вас нет прав на редактирование типа значков."))
|
||||
}
|
||||
|
||||
@ -379,7 +384,7 @@ func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *mo
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
|
||||
if !canEditType(u, p.badgeAdminUserIDs, typeDefinition) {
|
||||
return commandError(T("badges.error.cannot_edit_type", "У вас нет прав на редактирование этого типа"))
|
||||
}
|
||||
|
||||
@ -493,7 +498,7 @@ func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *
|
||||
}
|
||||
T := p.getT(u.Locale)
|
||||
|
||||
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||
if !canCreateType(u, p.badgeAdminUserIDs, false) {
|
||||
return commandError(T("badges.error.no_permissions_create_type", "У вас нет прав на создание типа значков."))
|
||||
}
|
||||
|
||||
@ -582,7 +587,7 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
return commandError(err.Error())
|
||||
}
|
||||
|
||||
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
|
||||
if !canGrantBadge(granter, p.badgeAdminUserIDs, badge, badgeType) {
|
||||
return commandError(T("badges.error.no_permissions_grant", "У вас нет прав на выдачу этого значка"))
|
||||
}
|
||||
|
||||
@ -592,6 +597,9 @@ func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model
|
||||
}
|
||||
|
||||
shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeStr), user.Id, extra.UserId, "")
|
||||
if err == errAlreadyOwned {
|
||||
return commandError(T("badges.error.already_owned", "Это достижение уже выдано этому пользователю"))
|
||||
}
|
||||
if err != nil {
|
||||
return commandError(err.Error())
|
||||
}
|
||||
@ -755,7 +763,7 @@ func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs)
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
|
||||
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
|
||||
}
|
||||
|
||||
@ -818,7 +826,7 @@ func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs)
|
||||
}
|
||||
T := p.getT(actingUser.Locale)
|
||||
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||
if !canCreateSubscription(actingUser, p.badgeAdminUserIDs, extra.ChannelId) {
|
||||
return commandError(T("badges.error.cannot_create_subscription", "Вы не можете создавать подписки"))
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@ -78,13 +79,20 @@ func (p *Plugin) OnConfigurationChange() error {
|
||||
return errors.Wrap(err, "failed to load plugin configuration")
|
||||
}
|
||||
|
||||
p.badgeAdminUserID = ""
|
||||
p.badgeAdminUserIDs = make(map[string]bool)
|
||||
if configuration.BadgesAdmin != "" {
|
||||
u, err := p.API.GetUserByUsername(configuration.BadgesAdmin)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot get badge admin user")
|
||||
for username := range strings.SplitSeq(configuration.BadgesAdmin, ",") {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
u, err := p.API.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
p.API.LogWarn("Cannot find badge admin user", "username", username, "error", err.Error())
|
||||
continue
|
||||
}
|
||||
p.badgeAdminUserIDs[u.Id] = true
|
||||
}
|
||||
p.badgeAdminUserID = u.Id
|
||||
}
|
||||
|
||||
p.setConfiguration(configuration)
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
[
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Create badge"},
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Create achievement"},
|
||||
{"id": "badges.dialog.create_badge.submit", "translation": "Create"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Edit badge"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Edit achievement"},
|
||||
{"id": "badges.dialog.edit_badge.submit", "translation": "Save"},
|
||||
{"id": "badges.dialog.create_type.title", "translation": "Create type"},
|
||||
{"id": "badges.dialog.create_type.submit", "translation": "Create"},
|
||||
{"id": "badges.dialog.edit_type.title", "translation": "Edit type"},
|
||||
{"id": "badges.dialog.edit_type.submit", "translation": "Save"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Grant badge"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Grant achievement"},
|
||||
{"id": "badges.dialog.grant.submit", "translation": "Grant"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Grant badge to @%s"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Grant achievement to @%s"},
|
||||
{"id": "badges.dialog.create_subscription.title", "translation": "Create subscription"},
|
||||
{"id": "badges.dialog.create_subscription.submit", "translation": "Add"},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Select the badge type you want to subscribe to this channel."},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Select the achievement type you want to subscribe to this channel."},
|
||||
{"id": "badges.dialog.delete_subscription.title", "translation": "Delete subscription"},
|
||||
{"id": "badges.dialog.delete_subscription.submit", "translation": "Remove"},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Select the badge type you want to unsubscribe from this channel."},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Select the achievement type you want to unsubscribe from this channel."},
|
||||
|
||||
{"id": "badges.field.name", "translation": "Name"},
|
||||
{"id": "badges.field.description", "translation": "Description"},
|
||||
@ -23,45 +23,46 @@
|
||||
{"id": "badges.field.image.help", "translation": "Enter an emoticon name"},
|
||||
{"id": "badges.field.type", "translation": "Type"},
|
||||
{"id": "badges.field.multiple", "translation": "Multiple"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Whether the badge can be granted multiple times"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Delete badge"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this badge permanently."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Everyone can create badge"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create a badge of this type"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Whether the achievement can be granted multiple times"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Delete achievement"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "WARNING: checking this will remove this achievement permanently."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Everyone can create achievement"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Whether any user can create an achievement of this type"},
|
||||
{"id": "badges.field.allowlist_create", "translation": "Can create allowlist"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create badges of this type."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant badge"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant a badge of this type"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Fill the usernames separated by comma (,) of the people that can create achievements of this type."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Everyone can grant achievement"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Whether any user can grant an achievement of this type"},
|
||||
{"id": "badges.field.allowlist_grant", "translation": "Can grant allowlist"},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant badges of this type."},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Fill the usernames separated by comma (,) of the people that can grant achievements of this type."},
|
||||
{"id": "badges.field.delete_type", "translation": "Remove type"},
|
||||
{"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated badges permanently."},
|
||||
{"id": "badges.field.delete_type.help", "translation": "WARNING: checking this will remove this type and all associated achievements permanently."},
|
||||
{"id": "badges.field.user", "translation": "User"},
|
||||
{"id": "badges.field.badge", "translation": "Badge"},
|
||||
{"id": "badges.field.badge", "translation": "Achievement"},
|
||||
{"id": "badges.field.reason", "translation": "Reason"},
|
||||
{"id": "badges.field.reason.help", "translation": "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."},
|
||||
{"id": "badges.field.reason.help", "translation": "Reason why you are granting this achievement. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions)."},
|
||||
{"id": "badges.field.notify_here", "translation": "Notify on this channel"},
|
||||
{"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person."},
|
||||
{"id": "badges.field.notify_here.help", "translation": "If you mark this, the bot will send a message to this channel notifying that you granted this achievement to this person."},
|
||||
|
||||
{"id": "badges.error.unknown", "translation": "An unknown error occurred. Please talk to your system administrator for help."},
|
||||
{"id": "badges.error.cannot_get_user", "translation": "Cannot get user."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the badges database."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Only a system admin can clean the achievements database."},
|
||||
{"id": "badges.error.specify_create", "translation": "Specify what you want to create."},
|
||||
{"id": "badges.error.create_badge_or_type", "translation": "You can create either badge or type"},
|
||||
{"id": "badges.error.no_types_available", "translation": "You cannot create badges from any type."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "You must set the badge ID"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this badge"},
|
||||
{"id": "badges.error.create_badge_or_type", "translation": "You can create either achievement or type"},
|
||||
{"id": "badges.error.no_types_available", "translation": "You cannot create achievements from any type."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "You must set the achievement ID"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "You cannot edit this achievement"},
|
||||
{"id": "badges.error.specify_edit", "translation": "Specify what you want to edit."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "You can edit either badge or type"},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit a badge type."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "You can edit either achievement or type"},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "You have no permissions to edit an achievement type."},
|
||||
{"id": "badges.error.must_provide_type_id", "translation": "You must provide a type id"},
|
||||
{"id": "badges.error.cannot_edit_type", "translation": "You cannot edit this type"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this badge"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that badge"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "You have no permissions to grant this achievement"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "You cannot grant that achievement"},
|
||||
{"id": "badges.error.specify_subscription", "translation": "Specify what you want to do."},
|
||||
{"id": "badges.error.create_or_delete_subscription", "translation": "You can either create or delete subscriptions"},
|
||||
{"id": "badges.error.cannot_create_subscription", "translation": "You cannot create subscriptions"},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create a badge type."},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "You have no permissions to create an achievement type."},
|
||||
{"id": "badges.error.already_owned", "translation": "This achievement is already owned by this user"},
|
||||
|
||||
{"id": "badges.success.clean", "translation": "Clean"},
|
||||
{"id": "badges.success.granted", "translation": "Granted"},
|
||||
@ -72,8 +73,8 @@
|
||||
{"id": "badges.api.empty_emoji", "translation": "Empty emoji"},
|
||||
{"id": "badges.api.invalid_field", "translation": "Invalid field"},
|
||||
{"id": "badges.api.type_not_exist", "translation": "This type does not exist"},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this badge"},
|
||||
{"id": "badges.api.badge_created", "translation": "Badge `%s` created."},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "You have no permissions to create this achievement"},
|
||||
{"id": "badges.api.badge_created", "translation": "Achievement `%s` created."},
|
||||
{"id": "badges.api.no_permissions_create_type", "translation": "You have no permissions to create a type"},
|
||||
{"id": "badges.api.cannot_find_user", "translation": "Cannot find user"},
|
||||
{"id": "badges.api.error_getting_user", "translation": "Error getting user %s: %v"},
|
||||
@ -83,24 +84,25 @@
|
||||
{"id": "badges.api.could_not_get_type", "translation": "Could not get the type"},
|
||||
{"id": "badges.api.no_permissions_edit_type", "translation": "You have no permissions to edit this type"},
|
||||
{"id": "badges.api.type_updated", "translation": "Type `%s` updated."},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Cannot get badge"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this badge"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Could not get the badge"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this badge"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Badge `%s` updated."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Badge not found"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this badge"},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Cannot get achievement"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "You cannot edit this achievement"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Could not get the achievement"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "You have no permissions to edit this achievement"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Achievement `%s` updated."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Achievement not found"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "You have no permissions to grant this achievement"},
|
||||
{"id": "badges.api.user_not_found", "translation": "User not found"},
|
||||
{"id": "badges.api.badge_granted", "translation": "Badge `%s` granted to @%s."},
|
||||
{"id": "badges.api.badge_granted", "translation": "Achievement `%s` granted to @%s."},
|
||||
{"id": "badges.api.cannot_create_subscription", "translation": "You cannot create a subscription"},
|
||||
{"id": "badges.api.subscription_added", "translation": "Subscription added"},
|
||||
{"id": "badges.api.cannot_delete_subscription", "translation": "You cannot delete a subscription"},
|
||||
{"id": "badges.api.subscription_removed", "translation": "Subscription removed"},
|
||||
{"id": "badges.api.cannot_delete_default_type", "translation": "Cannot delete the default type"},
|
||||
{"id": "badges.api.not_authorized", "translation": "Not authorized"},
|
||||
|
||||
{"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` badge."},
|
||||
{"id": "badges.notify.dm_text", "translation": "@%s granted you the %s`%s` achievement."},
|
||||
{"id": "badges.notify.dm_reason", "translation": "\nWhy? "},
|
||||
{"id": "badges.notify.title", "translation": "%sbadge granted!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` badge."},
|
||||
{"id": "badges.notify.title", "translation": "%sachievement granted!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s granted @%s the %s`%s` achievement."},
|
||||
{"id": "badges.notify.no_permission_channel", "translation": "You don't have permissions to notify the grant on this channel."}
|
||||
]
|
||||
]
|
||||
@ -18,6 +18,7 @@ type Bundle i18n.Bundle
|
||||
func Init() *Bundle {
|
||||
bundle := i18n.NewBundle(language.Russian)
|
||||
_, _ = bundle.LoadMessageFileFS(i18nFiles, "en.json")
|
||||
_, _ = bundle.LoadMessageFileFS(i18nFiles, "ru.json")
|
||||
|
||||
return (*Bundle)(bundle)
|
||||
}
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
[
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Создать значок"},
|
||||
{"id": "badges.dialog.create_badge.title", "translation": "Создать достижение"},
|
||||
{"id": "badges.dialog.create_badge.submit", "translation": "Создать"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Редактировать значок"},
|
||||
{"id": "badges.dialog.edit_badge.title", "translation": "Редактировать достижение"},
|
||||
{"id": "badges.dialog.edit_badge.submit", "translation": "Сохранить"},
|
||||
{"id": "badges.dialog.create_type.title", "translation": "Создать тип"},
|
||||
{"id": "badges.dialog.create_type.submit", "translation": "Создать"},
|
||||
{"id": "badges.dialog.edit_type.title", "translation": "Редактировать тип"},
|
||||
{"id": "badges.dialog.edit_type.submit", "translation": "Сохранить"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Выдать значок"},
|
||||
{"id": "badges.dialog.grant.title", "translation": "Выдать достижение"},
|
||||
{"id": "badges.dialog.grant.submit", "translation": "Выдать"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Выдать значок пользователю @%s"},
|
||||
{"id": "badges.dialog.grant.intro", "translation": "Выдать достижение пользователю @%s"},
|
||||
{"id": "badges.dialog.create_subscription.title", "translation": "Создать подписку"},
|
||||
{"id": "badges.dialog.create_subscription.submit", "translation": "Добавить"},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип значка, на который хотите подписать этот канал."},
|
||||
{"id": "badges.dialog.create_subscription.intro", "translation": "Выберите тип достижения, на который хотите подписать этот канал."},
|
||||
{"id": "badges.dialog.delete_subscription.title", "translation": "Удалить подписку"},
|
||||
{"id": "badges.dialog.delete_subscription.submit", "translation": "Удалить"},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип значка, подписку на который хотите удалить из этого канала."},
|
||||
{"id": "badges.dialog.delete_subscription.intro", "translation": "Выберите тип достижения, подписку на который хотите удалить из этого канала."},
|
||||
|
||||
{"id": "badges.field.name", "translation": "Название"},
|
||||
{"id": "badges.field.description", "translation": "Описание"},
|
||||
@ -23,45 +23,46 @@
|
||||
{"id": "badges.field.image.help", "translation": "Введите название эмодзи"},
|
||||
{"id": "badges.field.type", "translation": "Тип"},
|
||||
{"id": "badges.field.multiple", "translation": "Многократный"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Можно ли выдавать этот значок несколько раз"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Удалить значок"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, значок будет удалён безвозвратно."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Все могут создавать значки"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать значок этого типа"},
|
||||
{"id": "badges.field.multiple.help", "translation": "Можно ли выдавать это достижение несколько раз"},
|
||||
{"id": "badges.field.delete_badge", "translation": "Удалить достижение"},
|
||||
{"id": "badges.field.delete_badge.help", "translation": "ВНИМАНИЕ: если отметить, достижение будет удалён безвозвратно."},
|
||||
{"id": "badges.field.everyone_can_create", "translation": "Все могут создавать достижения"},
|
||||
{"id": "badges.field.everyone_can_create.help", "translation": "Любой пользователь может создать достижение этого типа"},
|
||||
{"id": "badges.field.allowlist_create", "translation": "Список допущенных к созданию"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать значки этого типа."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать значки"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать значок этого типа"},
|
||||
{"id": "badges.field.allowlist_create.help", "translation": "Укажите имена пользователей через запятую (,), которые могут создавать достижения этого типа."},
|
||||
{"id": "badges.field.everyone_can_grant", "translation": "Все могут выдавать достижения"},
|
||||
{"id": "badges.field.everyone_can_grant.help", "translation": "Любой пользователь может выдать достижение этого типа"},
|
||||
{"id": "badges.field.allowlist_grant", "translation": "Список допущенных к выдаче"},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать значки этого типа."},
|
||||
{"id": "badges.field.allowlist_grant.help", "translation": "Укажите имена пользователей через запятую (,), которые могут выдавать достижения этого типа."},
|
||||
{"id": "badges.field.delete_type", "translation": "Удалить тип"},
|
||||
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные значки будут удалены безвозвратно."},
|
||||
{"id": "badges.field.delete_type.help", "translation": "ВНИМАНИЕ: если отметить, этот тип и все связанные достижения будут удалены безвозвратно."},
|
||||
{"id": "badges.field.user", "translation": "Пользователь"},
|
||||
{"id": "badges.field.badge", "translation": "Значок"},
|
||||
{"id": "badges.field.badge", "translation": "Достижение"},
|
||||
{"id": "badges.field.reason", "translation": "Причина"},
|
||||
{"id": "badges.field.reason.help", "translation": "Причина выдачи значка. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
|
||||
{"id": "badges.field.reason.help", "translation": "Причина выдачи достижения. Будет видна пользователю и в уведомлениях о выдаче (например, в подписках)."},
|
||||
{"id": "badges.field.notify_here", "translation": "Уведомить в этом канале"},
|
||||
{"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали значок этому пользователю."},
|
||||
{"id": "badges.field.notify_here.help", "translation": "Если отметить, бот отправит сообщение в этот канал о том, что вы выдали достижение этому пользователю."},
|
||||
|
||||
{"id": "badges.error.unknown", "translation": "Произошла неизвестная ошибка. Обратитесь к системному администратору."},
|
||||
{"id": "badges.error.cannot_get_user", "translation": "Не удалось получить пользователя."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу значков."},
|
||||
{"id": "badges.error.only_sysadmin_clean", "translation": "Только системный администратор может очистить базу достижений."},
|
||||
{"id": "badges.error.specify_create", "translation": "Укажите, что вы хотите создать."},
|
||||
{"id": "badges.error.create_badge_or_type", "translation": "Можно создать badge или type"},
|
||||
{"id": "badges.error.no_types_available", "translation": "Вы не можете создать значки ни одного типа."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID значка"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого значка"},
|
||||
{"id": "badges.error.no_types_available", "translation": "Вы не можете создать достижения ни одного типа."},
|
||||
{"id": "badges.error.must_set_badge_id", "translation": "Необходимо указать ID достижения"},
|
||||
{"id": "badges.error.cannot_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"},
|
||||
{"id": "badges.error.specify_edit", "translation": "Укажите, что вы хотите отредактировать."},
|
||||
{"id": "badges.error.edit_badge_or_type", "translation": "Можно редактировать badge или type"},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа значков."},
|
||||
{"id": "badges.error.no_permissions_edit_type", "translation": "У вас нет прав на редактирование типа достижений."},
|
||||
{"id": "badges.error.must_provide_type_id", "translation": "Необходимо указать ID типа"},
|
||||
{"id": "badges.error.cannot_edit_type", "translation": "У вас нет прав на редактирование этого типа"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого значка"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать этот значок"},
|
||||
{"id": "badges.error.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"},
|
||||
{"id": "badges.error.cannot_grant_badge", "translation": "Вы не можете выдать это достижение"},
|
||||
{"id": "badges.error.specify_subscription", "translation": "Укажите, что вы хотите сделать."},
|
||||
{"id": "badges.error.create_or_delete_subscription", "translation": "Можно создать или удалить подписку"},
|
||||
{"id": "badges.error.cannot_create_subscription", "translation": "Вы не можете создавать подписки"},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа значков."},
|
||||
{"id": "badges.error.no_permissions_create_type", "translation": "У вас нет прав на создание типа достижений."},
|
||||
{"id": "badges.error.already_owned", "translation": "Это достижение уже выдано этому пользователю"},
|
||||
|
||||
{"id": "badges.success.clean", "translation": "Очищено"},
|
||||
{"id": "badges.success.granted", "translation": "Выдано"},
|
||||
@ -72,8 +73,8 @@
|
||||
{"id": "badges.api.empty_emoji", "translation": "Пустой эмодзи"},
|
||||
{"id": "badges.api.invalid_field", "translation": "Некорректное поле"},
|
||||
{"id": "badges.api.type_not_exist", "translation": "Этот тип не существует"},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого значка"},
|
||||
{"id": "badges.api.badge_created", "translation": "Значок `%s` создан."},
|
||||
{"id": "badges.api.no_permissions_create_badge", "translation": "У вас нет прав на создание этого достижения"},
|
||||
{"id": "badges.api.badge_created", "translation": "Достижение `%s` создано."},
|
||||
{"id": "badges.api.no_permissions_create_type", "translation": "У вас нет прав на создание типа"},
|
||||
{"id": "badges.api.cannot_find_user", "translation": "Не удалось найти пользователя"},
|
||||
{"id": "badges.api.error_getting_user", "translation": "Ошибка получения пользователя %s: %v"},
|
||||
@ -83,24 +84,25 @@
|
||||
{"id": "badges.api.could_not_get_type", "translation": "Не удалось получить тип"},
|
||||
{"id": "badges.api.no_permissions_edit_type", "translation": "У вас нет прав на редактирование этого типа"},
|
||||
{"id": "badges.api.type_updated", "translation": "Тип `%s` обновлён."},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить значок"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать этот значок"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить значок"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого значка"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Значок `%s` обновлён."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Значок не найден"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого значка"},
|
||||
{"id": "badges.api.cannot_get_badge", "translation": "Не удалось получить достижение"},
|
||||
{"id": "badges.api.cannot_edit_badge", "translation": "Вы не можете редактировать это достижение"},
|
||||
{"id": "badges.api.could_not_get_badge", "translation": "Не удалось получить достижение"},
|
||||
{"id": "badges.api.no_permissions_edit_badge", "translation": "У вас нет прав на редактирование этого достижения"},
|
||||
{"id": "badges.api.badge_updated", "translation": "Достижение `%s` обновлёно."},
|
||||
{"id": "badges.api.badge_not_found", "translation": "Достижение не найдено"},
|
||||
{"id": "badges.api.no_permissions_grant", "translation": "У вас нет прав на выдачу этого достижения"},
|
||||
{"id": "badges.api.user_not_found", "translation": "Пользователь не найден"},
|
||||
{"id": "badges.api.badge_granted", "translation": "Значок `%s` выдан @%s."},
|
||||
{"id": "badges.api.badge_granted", "translation": "Достижение `%s` выдан @%s."},
|
||||
{"id": "badges.api.cannot_create_subscription", "translation": "Вы не можете создать подписку"},
|
||||
{"id": "badges.api.subscription_added", "translation": "Подписка добавлена"},
|
||||
{"id": "badges.api.cannot_delete_subscription", "translation": "Вы не можете удалить подписку"},
|
||||
{"id": "badges.api.subscription_removed", "translation": "Подписка удалена"},
|
||||
{"id": "badges.api.cannot_delete_default_type", "translation": "Нельзя удалить тип по умолчанию"},
|
||||
{"id": "badges.api.not_authorized", "translation": "Не авторизован"},
|
||||
|
||||
{"id": "badges.notify.dm_text", "translation": "@%s выдал вам значок %s`%s`."},
|
||||
{"id": "badges.notify.dm_text", "translation": "@%s выдал вам достижение %s`%s`."},
|
||||
{"id": "badges.notify.dm_reason", "translation": "\nПочему? "},
|
||||
{"id": "badges.notify.title", "translation": "%sзначок выдан!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s значок %s`%s`."},
|
||||
{"id": "badges.notify.title", "translation": "%sдостижение выдано!"},
|
||||
{"id": "badges.notify.channel_text", "translation": "@%s выдал @%s достижение %s`%s`."},
|
||||
{"id": "badges.notify.no_permission_channel", "translation": "У вас нет прав на отправку уведомления о выдаче в этот канал."}
|
||||
]
|
||||
|
||||
@ -28,7 +28,7 @@ type Plugin struct {
|
||||
BotUserID string
|
||||
store Store
|
||||
router *mux.Router
|
||||
badgeAdminUserID string
|
||||
badgeAdminUserIDs map[string]bool
|
||||
i18nBundle *i18n.Bundle
|
||||
}
|
||||
|
||||
@ -57,6 +57,9 @@ func (p *Plugin) OnActivate() error {
|
||||
}
|
||||
p.BotUserID = botID
|
||||
p.store = NewStore(p.API)
|
||||
if err := p.store.EnsureDefaultType(p.BotUserID); err != nil {
|
||||
p.mm.Log.Warn("Failed to ensure default type", "error", err.Error())
|
||||
}
|
||||
p.i18nBundle = i18n.Init()
|
||||
p.initializeAPI()
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
var errInvalidBadge = errors.New("invalid badge")
|
||||
var errBadgeNotFound = errors.New("badge not found")
|
||||
var errAlreadyOwned = errors.New("already owned")
|
||||
|
||||
type Store interface {
|
||||
// Interface
|
||||
@ -33,12 +34,17 @@ type Store interface {
|
||||
UpdateBadge(b *badgesmodel.Badge) error
|
||||
DeleteType(tID badgesmodel.BadgeType) error
|
||||
DeleteBadge(bID badgesmodel.BadgeID) error
|
||||
RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error
|
||||
FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error)
|
||||
|
||||
AddSubscription(tID badgesmodel.BadgeType, cID string) error
|
||||
RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error
|
||||
GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error)
|
||||
GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error)
|
||||
|
||||
// Default type
|
||||
EnsureDefaultType(botID string) error
|
||||
|
||||
// PAPI
|
||||
EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error)
|
||||
}
|
||||
@ -144,6 +150,28 @@ func (s *store) addType(t *badgesmodel.BadgeTypeDefinition, isPlugin bool) (*bad
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (s *store) EnsureDefaultType(botID string) error {
|
||||
types, _, err := s.getAllTypes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range types {
|
||||
if t.IsDefault {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.addType(&badgesmodel.BadgeTypeDefinition{
|
||||
Name: badgesmodel.DefaultTypeName,
|
||||
IsDefault: true,
|
||||
CreatedBy: botID,
|
||||
CanCreate: badgesmodel.PermissionScheme{Everyone: true},
|
||||
CanGrant: badgesmodel.PermissionScheme{Everyone: true},
|
||||
}, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error) {
|
||||
badges, _, err := s.getAllBadges()
|
||||
if err != nil {
|
||||
@ -417,6 +445,11 @@ func (s *store) atomicDeleteType(tID badgesmodel.BadgeType) (bool, error) {
|
||||
}
|
||||
|
||||
func (s *store) DeleteType(tID badgesmodel.BadgeType) error {
|
||||
t, err := s.GetType(tID)
|
||||
if err == nil && t.IsDefault {
|
||||
return errors.New("cannot delete default type")
|
||||
}
|
||||
|
||||
s.doAtomic(func() (bool, error) { return s.atomicDeleteType(tID) })
|
||||
|
||||
bb, _, err := s.getAllBadges()
|
||||
@ -473,6 +506,25 @@ func (s *store) AddSubscription(tID badgesmodel.BadgeType, cID string) error {
|
||||
return s.doAtomic(func() (bool, error) { return s.atomicAddSubscription(toAdd) })
|
||||
}
|
||||
|
||||
func (s *store) FindOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (*badgesmodel.Ownership, error) {
|
||||
ownership, _, err := s.getOwnershipList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, o := range ownership {
|
||||
if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime {
|
||||
return &o, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("ownership not found")
|
||||
}
|
||||
|
||||
func (s *store) RevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) error {
|
||||
return s.doAtomic(func() (bool, error) { return s.atomicRevokeOwnership(badgeID, userID, grantTime) })
|
||||
}
|
||||
|
||||
func (s *store) RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error {
|
||||
toRemove := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
|
||||
return s.doAtomic(func() (bool, error) { return s.atomicRemoveSubscription(toRemove) })
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||
)
|
||||
@ -107,7 +108,7 @@ func (s *store) atomicAddBadgeToOwnership(o badgesmodel.Ownership, isMultiple bo
|
||||
}
|
||||
|
||||
if !isMultiple && ownership.IsOwned(o.User, o.Badge) {
|
||||
return false, true, nil
|
||||
return false, true, errAlreadyOwned
|
||||
}
|
||||
|
||||
ownership = append(ownership, o)
|
||||
@ -159,6 +160,28 @@ func (s *store) atomicUpdateBadge(b *badgesmodel.Badge) (bool, error) {
|
||||
return s.compareAndSet(KVKeyBadges, data, bb)
|
||||
}
|
||||
|
||||
func (s *store) atomicRevokeOwnership(badgeID badgesmodel.BadgeID, userID string, grantTime string) (bool, error) {
|
||||
ownership, data, err := s.getOwnershipList()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
found := false
|
||||
for i, o := range ownership {
|
||||
if o.Badge == badgeID && o.User == userID && o.Time.Format(time.RFC3339Nano) == grantTime {
|
||||
ownership = append(ownership[:i], ownership[i+1:]...)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return s.compareAndSet(KVKeyOwnership, data, ownership)
|
||||
}
|
||||
|
||||
func (s *store) atomicAddSubscription(toAdd badgesmodel.Subscription) (bool, error) {
|
||||
subs, data, err := s.getAllSubscriptions()
|
||||
if err != nil {
|
||||
|
||||
@ -23,7 +23,7 @@ func (p *Plugin) filterGrantBadges(user *model.User) ([]*badgesmodel.Badge, erro
|
||||
p.mm.Log.Debug("Badge with missing type", "badge", b)
|
||||
continue
|
||||
}
|
||||
if canGrantBadge(user, p.badgeAdminUserID, b, badgeType) {
|
||||
if canGrantBadge(user, p.badgeAdminUserIDs, b, badgeType) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,7 @@ func (p *Plugin) filterCreateBadgeTypes(user *model.User) (badgesmodel.BadgeType
|
||||
|
||||
out := badgesmodel.BadgeTypeList{}
|
||||
for _, t := range types {
|
||||
if canCreateBadge(user, p.badgeAdminUserID, t) {
|
||||
if canCreateBadge(user, p.badgeAdminUserIDs, t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@ func (p *Plugin) filterEditTypes(user *model.User) (badgesmodel.BadgeTypeList, e
|
||||
|
||||
out := badgesmodel.BadgeTypeList{}
|
||||
for _, t := range types {
|
||||
if canEditType(user, p.badgeAdminUserID, t) {
|
||||
if canEditType(user, p.badgeAdminUserIDs, t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
@ -69,9 +69,18 @@ func (p *Plugin) filterEditBadges(user *model.User) ([]*badgesmodel.Badge, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
typeCache := map[badgesmodel.BadgeType]*badgesmodel.BadgeTypeDefinition{}
|
||||
out := []*badgesmodel.Badge{}
|
||||
for _, b := range bb {
|
||||
if canEditBadge(user, p.badgeAdminUserID, b) {
|
||||
bt, ok := typeCache[b.Type]
|
||||
if !ok {
|
||||
bt, err = p.store.GetType(b.Type)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
typeCache[b.Type] = bt
|
||||
}
|
||||
if canEditBadge(user, p.badgeAdminUserIDs, b, bt) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||
"github.com/mattermost/mattermost-server/v5/model"
|
||||
@ -23,8 +24,8 @@ func areRolesAllowed(userRoles []string, allowedRoles map[string]bool) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canGrantBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -57,8 +58,8 @@ func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Bad
|
||||
return badgeType.CanGrant.Everyone
|
||||
}
|
||||
|
||||
func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canCreateBadge(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -87,36 +88,44 @@ func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmode
|
||||
return badgeType.CanCreate.Everyone
|
||||
}
|
||||
|
||||
func canEditType(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canEditType(user *model.User, badgeAdminIDs map[string]bool, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.IsSystemAdmin()
|
||||
}
|
||||
|
||||
func canEditBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canEditBadge(user *model.User, badgeAdminIDs map[string]bool, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.IsSystemAdmin() || user.Id == badge.CreatedBy
|
||||
if user.IsSystemAdmin() {
|
||||
return true
|
||||
}
|
||||
|
||||
if badgeType != nil && canCreateBadge(user, badgeAdminIDs, badgeType) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool {
|
||||
func canCreateType(user *model.User, badgeAdminIDs map[string]bool, isPlugin bool) bool {
|
||||
if isPlugin {
|
||||
return true
|
||||
}
|
||||
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
return user.IsSystemAdmin()
|
||||
}
|
||||
|
||||
func canCreateSubscription(user *model.User, badgeAdminID string, channelID string) bool {
|
||||
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||
func canCreateSubscription(user *model.User, badgeAdminIDs map[string]bool, channelID string) bool {
|
||||
if badgeAdminIDs[user.Id] {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -160,8 +169,9 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
dmText += Tdm("badges.notify.dm_reason", "\nПочему? ") + reason
|
||||
}
|
||||
dmAttachment := model.SlackAttachment{
|
||||
Title: Tdm("badges.notify.title", "%sзначок выдан!", image),
|
||||
Text: dmText,
|
||||
Fallback: dmText,
|
||||
Title: Tdm("badges.notify.title", "%sзначок выдан!", image),
|
||||
Text: dmText,
|
||||
}
|
||||
model.ParseSlackAttachment(dmPost, []*model.SlackAttachment{&dmAttachment})
|
||||
err := p.mm.Post.DM(p.BotUserID, granted.Id, dmPost)
|
||||
@ -192,7 +202,14 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
p.mm.Log.Debug("notify subscription error", "err", err)
|
||||
}
|
||||
}
|
||||
if inChannel {
|
||||
alreadyNotified := false
|
||||
for _, sub := range subs {
|
||||
if sub == channelID {
|
||||
alreadyNotified = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if inChannel && !alreadyNotified {
|
||||
if !p.API.HasPermissionToChannel(granter, channelID, model.PERMISSION_CREATE_POST) {
|
||||
Tg := p.getT(granterUser.Locale)
|
||||
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: Tg("badges.notify.no_permission_channel", "У вас нет прав на отправку уведомления о выдаче в этот канал."), ChannelId: channelID})
|
||||
@ -208,6 +225,40 @@ func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, grante
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUsernameList parses a comma-separated list of usernames and returns a map of user IDs.
|
||||
func (p *Plugin) resolveUsernameList(csv string) (map[string]bool, error) {
|
||||
result := map[string]bool{}
|
||||
usernames := strings.Split(csv, ",")
|
||||
for _, username := range usernames {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
user, err := p.mm.User.GetByUsername(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %s", username)
|
||||
}
|
||||
result[user.Id] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveUserIDList converts a map of user IDs to a comma-separated list of usernames.
|
||||
func (p *Plugin) resolveUserIDList(ids map[string]bool) string {
|
||||
var names []string
|
||||
for id, allowed := range ids {
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
user, err := p.mm.User.Get(id)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
names = append(names, user.Username)
|
||||
}
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func getBooleanString(in bool) string {
|
||||
if in {
|
||||
return TrueString
|
||||
|
||||
@ -194,22 +194,14 @@
|
||||
"skipComments": false
|
||||
}
|
||||
],
|
||||
"max-nested-callbacks": [
|
||||
2,
|
||||
{
|
||||
"max": 2
|
||||
}
|
||||
],
|
||||
"max-nested-callbacks": 0,
|
||||
"max-statements-per-line": [
|
||||
2,
|
||||
{
|
||||
"max": 1
|
||||
}
|
||||
],
|
||||
"multiline-ternary": [
|
||||
1,
|
||||
"never"
|
||||
],
|
||||
"multiline-ternary": 0,
|
||||
"new-cap": 2,
|
||||
"new-parens": 2,
|
||||
"newline-before-return": 0,
|
||||
@ -415,10 +407,7 @@
|
||||
2,
|
||||
"always"
|
||||
],
|
||||
"operator-linebreak": [
|
||||
2,
|
||||
"after"
|
||||
],
|
||||
"operator-linebreak": 0,
|
||||
"padded-blocks": [
|
||||
2,
|
||||
"never"
|
||||
@ -697,7 +686,8 @@
|
||||
{
|
||||
"extensions": [".jsx", ".tsx"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"react/prop-types": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,40 +1,181 @@
|
||||
{
|
||||
"badges.loading": "Loading...",
|
||||
"badges.no_badges_yet": "No badges yet.",
|
||||
"badges.badge_not_found": "Badge not found.",
|
||||
"badges.no_badges_yet": "No achievements yet.",
|
||||
"badges.empty.title": "No achievements yet",
|
||||
"badges.empty.description": "Create your first achievement to recognize contributions of your team members.",
|
||||
"badges.badge_not_found": "Achievement not found.",
|
||||
"badges.user_not_found": "User not found.",
|
||||
"badges.unknown": "unknown",
|
||||
|
||||
"badges.rhs.all_badges": "All badges",
|
||||
"badges.rhs.my_badges": "My badges",
|
||||
"badges.rhs.user_badges": "@{username}'s badges",
|
||||
"badges.rhs.badge_details": "Badge Details",
|
||||
"badges.rhs.all_badges": "All achievements",
|
||||
"badges.rhs.my_badges": "My achievements",
|
||||
"badges.rhs.user_badges": "@{username}'s achievements",
|
||||
"badges.rhs.badge_details": "Achievement Details",
|
||||
|
||||
"badges.label.name": "Name:",
|
||||
"badges.label.description": "Description:",
|
||||
"badges.label.type": "Type: {typeName}",
|
||||
"badges.label.created_by": "Created by: {username}",
|
||||
"badges.label.granted_by": "Granted by: {username}",
|
||||
"badges.label.granted_at": "Granted at: {date}",
|
||||
"badges.label.reason": "Why? {reason}",
|
||||
"badges.label.count": "Count: {count}",
|
||||
|
||||
"badges.granted.not_yet": "Not yet granted.",
|
||||
"badges.granted.multiple": "Granted {times, plural, one {# time} other {# times}} to {users, plural, one {# user} other {# users}}.",
|
||||
"badges.granted.single": "Granted to {users, plural, one {# user} other {# users}}.",
|
||||
"badges.granted_to": "Granted to:",
|
||||
"badges.not_granted_yet": "Not granted to anyone yet",
|
||||
|
||||
"badges.set_status": "Set status to this badge",
|
||||
"badges.grant_badge": "Grant badge",
|
||||
"badges.set_status": "Set status to this achievement",
|
||||
"badges.grant_badge": "Grant achievement",
|
||||
"badges.and_more": "and {count} more. Click to see all.",
|
||||
|
||||
"badges.menu.open_list": "Open the list of all badges.",
|
||||
"badges.menu.create_badge": "Create badge",
|
||||
"badges.menu.create_type": "Create badge type",
|
||||
"badges.menu.add_subscription": "Add badge subscription",
|
||||
"badges.menu.remove_subscription": "Remove badge subscription",
|
||||
"badges.menu.open_list": "Achievements",
|
||||
"badges.menu.create_badge": "Create achievement",
|
||||
"badges.menu.create_type": "Create achievement type",
|
||||
"badges.menu.add_subscription": "Add achievement subscription",
|
||||
"badges.menu.remove_subscription": "Remove achievement subscription",
|
||||
|
||||
"badges.sidebar.title": "Badges",
|
||||
"badges.popover.title": "Badges",
|
||||
"badges.sidebar.title": "Achievements",
|
||||
"badges.popover.title": "Achievements",
|
||||
|
||||
"badges.admin.label": "Achievements Admin:",
|
||||
"badges.admin.placeholder": "username",
|
||||
"badges.admin.help_text": "This user will be considered the achievements plugin administrator. They can create types, as well as modify and grant any badges."
|
||||
"badges.admin.label": "Achievements Administrators:",
|
||||
"badges.admin.placeholder": "Start typing a name...",
|
||||
"badges.admin.help_text": "These users will be considered achievements plugin administrators. They can create types, as well as modify and grant any achievements.",
|
||||
"badges.admin.no_results": "No users found",
|
||||
|
||||
"badges.rhs.create_badge": "+ Create achievement",
|
||||
"badges.rhs.edit_badge": "Edit",
|
||||
"badges.rhs.types": "Types",
|
||||
"badges.rhs.create_type": "+ Create type",
|
||||
|
||||
"badges.modal.create_badge_title": "Create Achievement",
|
||||
"badges.modal.edit_badge_title": "Edit Achievement",
|
||||
"badges.modal.field_name": "Name",
|
||||
"badges.modal.field_name_placeholder": "Achievement name (max 20 chars)",
|
||||
"badges.modal.field_description": "Description",
|
||||
"badges.modal.field_description_placeholder": "Achievement description (max 120 chars)",
|
||||
"badges.modal.field_image": "Emoji",
|
||||
"badges.modal.field_image_placeholder": "Emoji name (e.g. star)",
|
||||
"badges.modal.field_type": "Type",
|
||||
"badges.modal.field_type_placeholder": "Select achievement type",
|
||||
"badges.modal.field_multiple": "Can be granted multiple times",
|
||||
"badges.modal.create_new_type": "+ Create new type",
|
||||
"badges.modal.new_type_name": "Type name",
|
||||
"badges.modal.new_type_name_placeholder": "Type name (max 20 chars)",
|
||||
"badges.modal.new_type_everyone_create": "Everyone can create achievements",
|
||||
"badges.modal.new_type_everyone_grant": "Everyone can grant achievements",
|
||||
"badges.modal.btn_cancel": "Cancel",
|
||||
"badges.modal.btn_create": "Create",
|
||||
"badges.modal.btn_save": "Save",
|
||||
"badges.modal.btn_creating": "Saving...",
|
||||
"badges.modal.btn_delete": "Delete achievement",
|
||||
"badges.modal.btn_confirm_delete": "Yes, delete",
|
||||
"badges.modal.confirm_delete": "Are you sure?",
|
||||
"badges.modal.confirm_delete_badge": "Delete achievement \"{name}\"?",
|
||||
"badges.modal.error_generic": "An error occurred",
|
||||
"badges.modal.error_type_name_required": "Enter type name",
|
||||
"badges.modal.error_type_required": "Select achievement type",
|
||||
"badges.modal.error_duplicate_name": "An achievement with this name already exists in this type",
|
||||
"badges.modal.error_not_found_emoji": "This emoji was not found",
|
||||
"badges.modal.create_type_title": "Create Type",
|
||||
"badges.modal.edit_type_title": "Edit Type",
|
||||
"badges.modal.btn_delete_type": "Delete type",
|
||||
"badges.modal.delete_type": "Delete type",
|
||||
"badges.modal.confirm_delete_type": "Delete type \"{name}\"?",
|
||||
"badges.modal.btn_confirm_delete_type": "Yes, delete",
|
||||
|
||||
"badges.types.badge_count": "{count, plural, one {# achievement} other {# achievements}}",
|
||||
"badges.types.everyone_can_create": "Everyone creates",
|
||||
"badges.types.everyone_can_grant": "Everyone grants",
|
||||
"badges.types.is_default": "Default",
|
||||
"badges.types.confirm_delete": "Delete type \"{name}\" and all its achievements?",
|
||||
"badges.types.empty": "No types yet",
|
||||
"badges.types.no_badges": "No achievements in this type",
|
||||
"badges.rhs.back_to_types": "Back to types",
|
||||
"badges.rhs.back_to_achievements": "Back to achievements",
|
||||
|
||||
"badges.modal.allowlist_create": "Allowlist for creation",
|
||||
"badges.modal.allowlist_create_help": "Users who can create achievements of this type.",
|
||||
"badges.modal.allowlist_grant": "Allowlist for granting",
|
||||
"badges.modal.allowlist_grant_help": "Users who can grant achievements of this type.",
|
||||
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
|
||||
|
||||
"badges.grant.title": "Grant Achievement",
|
||||
"badges.grant.intro": "Grant achievement to @{username}",
|
||||
"badges.grant.field_badge": "Achievement",
|
||||
"badges.grant.field_badge_placeholder": "Select an achievement",
|
||||
"badges.grant.no_badges": "No achievements available",
|
||||
"badges.grant.field_reason": "Reason",
|
||||
"badges.grant.field_reason_placeholder": "Why is this achievement being granted? (optional)",
|
||||
"badges.grant.notify_here": "Notify in channel",
|
||||
"badges.grant.btn_grant": "Grant",
|
||||
|
||||
"badges.revoke.btn": "Revoke",
|
||||
"badges.revoke.confirm": "Revoke achievement?",
|
||||
"badges.revoke.confirm_yes": "Yes",
|
||||
|
||||
"badges.subscription.title_create": "Add Subscription",
|
||||
"badges.subscription.title_delete": "Remove Subscription",
|
||||
"badges.subscription.field_type": "Achievement Type",
|
||||
"badges.subscription.field_type_placeholder": "Select achievement type",
|
||||
"badges.subscription.no_types": "No types available",
|
||||
"badges.subscription.btn_create": "Add",
|
||||
"badges.subscription.btn_delete": "Remove",
|
||||
|
||||
"badges.error.invalid_badge_id": "Achievement not specified",
|
||||
"badges.error.invalid_user_id": "User not specified",
|
||||
"badges.error.no_permission_grant": "Insufficient permissions to grant this achievement",
|
||||
"badges.error.cannot_grant_badge": "Failed to grant achievement",
|
||||
"badges.error.user_not_found": "User not found",
|
||||
"badges.error.invalid_type_id": "Achievement type not specified",
|
||||
"badges.error.no_permission_subscription": "Insufficient permissions to manage subscriptions",
|
||||
"badges.error.cannot_create_subscription": "Failed to create subscription",
|
||||
"badges.error.cannot_delete_subscription": "Failed to delete subscription",
|
||||
"badges.error.ownership_not_found": "Ownership not found",
|
||||
"badges.error.no_permission_revoke": "Insufficient permissions to revoke",
|
||||
"badges.error.cannot_revoke": "Failed to revoke",
|
||||
"badges.error.already_owned": "This achievement is already owned by this user",
|
||||
|
||||
"badges.error.unknown": "An error occurred",
|
||||
"badges.error.cannot_get_user": "Failed to get user data",
|
||||
"badges.error.cannot_get_types": "Failed to load types",
|
||||
"badges.error.cannot_get_badges": "Failed to load achievements",
|
||||
"badges.error.invalid_request": "Invalid request format",
|
||||
"badges.error.invalid_name": "Name is required",
|
||||
"badges.error.invalid_image": "Emoji is required",
|
||||
"badges.error.type_not_found": "Achievement type not found",
|
||||
"badges.error.badge_not_found": "Achievement not found",
|
||||
"badges.error.no_permission": "Insufficient permissions",
|
||||
"badges.error.missing_badge_id": "Achievement ID is missing",
|
||||
"badges.error.missing_type_id": "Type ID is missing",
|
||||
"badges.error.cannot_create_badge": "Failed to create achievement",
|
||||
"badges.error.cannot_create_type": "Failed to create type",
|
||||
"badges.error.cannot_update_badge": "Failed to update achievement",
|
||||
"badges.error.cannot_delete_badge": "Failed to delete achievement",
|
||||
"badges.error.cannot_update_type": "Failed to update type",
|
||||
"badges.error.cannot_delete_type": "Failed to delete type",
|
||||
|
||||
"emoji_picker.activities": "Activities",
|
||||
"emoji_picker.animals-nature": "Animals & Nature",
|
||||
"emoji_picker.close": "Close",
|
||||
"emoji_picker.custom": "Custom",
|
||||
"emoji_picker.custom_emoji": "Custom Emoji",
|
||||
"emoji_picker.emojiPicker.button.ariaLabel": "select an emoji",
|
||||
"emoji_picker.emojiPicker.previewPlaceholder": "Select an Emoji",
|
||||
"emoji_picker.flags": "Flags",
|
||||
"emoji_picker.food-drink": "Food & Drink",
|
||||
"emoji_picker.header": "Emoji Picker",
|
||||
"emoji_picker.objects": "Objects",
|
||||
"emoji_picker.people-body": "People & Body",
|
||||
"emoji_picker.recent": "Recently Used",
|
||||
"emoji_picker.search": "Search emojis",
|
||||
"emoji_picker.searchResults": "Search Results",
|
||||
"emoji_picker.search_emoji": "Search for an emoji",
|
||||
"emoji_picker.skin_tone": "Skin tone",
|
||||
"emoji_picker.smileys-emotion": "Smileys & Emotion",
|
||||
"emoji_picker.symbols": "Symbols",
|
||||
"emoji_picker.travel-places": "Travel Places",
|
||||
"emoji_picker_item.emoji_aria_label": "{emojiName} emoji"
|
||||
}
|
||||
|
||||
@ -1,40 +1,181 @@
|
||||
{
|
||||
"badges.loading": "Загрузка...",
|
||||
"badges.no_badges_yet": "Значков пока нет.",
|
||||
"badges.badge_not_found": "Значок не найден.",
|
||||
"badges.no_badges_yet": "Достижений пока нет.",
|
||||
"badges.empty.title": "Достижений пока нет",
|
||||
"badges.empty.description": "Создайте первое достижение, чтобы отмечать заслуги участников команды.",
|
||||
"badges.badge_not_found": "Достижение не найдено.",
|
||||
"badges.user_not_found": "Пользователь не найден.",
|
||||
"badges.unknown": "неизвестно",
|
||||
|
||||
"badges.rhs.all_badges": "Все значки",
|
||||
"badges.rhs.my_badges": "Мои значки",
|
||||
"badges.rhs.user_badges": "Значки @{username}",
|
||||
"badges.rhs.badge_details": "Детали значка",
|
||||
"badges.rhs.all_badges": "Все достижения",
|
||||
"badges.rhs.my_badges": "Мои достижения",
|
||||
"badges.rhs.user_badges": "Достижения @{username}",
|
||||
"badges.rhs.badge_details": "Детали достижения",
|
||||
|
||||
"badges.label.name": "Название:",
|
||||
"badges.label.description": "Описание:",
|
||||
"badges.label.type": "Тип: {typeName}",
|
||||
"badges.label.created_by": "Создал: {username}",
|
||||
"badges.label.granted_by": "Выдал: {username}",
|
||||
"badges.label.granted_at": "Выдан: {date}",
|
||||
"badges.label.reason": "Причина: {reason}",
|
||||
"badges.label.count": "Количество: {count}",
|
||||
|
||||
"badges.granted.not_yet": "Ещё не выдан.",
|
||||
"badges.granted.multiple": "Выдан {times, plural, one {# раз} few {# раза} many {# раз} other {# раз}} {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.",
|
||||
"badges.granted.single": "Выдан {users, plural, one {# пользователю} few {# пользователям} many {# пользователям} other {# пользователям}}.",
|
||||
"badges.granted_to": "Выдан:",
|
||||
"badges.not_granted_yet": "Ещё никому не выдан",
|
||||
|
||||
"badges.set_status": "Установить как статус",
|
||||
"badges.grant_badge": "Выдать значок",
|
||||
"badges.grant_badge": "Выдать достижение",
|
||||
"badges.and_more": "и ещё {count}. Нажмите, чтобы увидеть все.",
|
||||
|
||||
"badges.menu.open_list": "Открыть список всех значков.",
|
||||
"badges.menu.create_badge": "Создать значок",
|
||||
"badges.menu.create_type": "Создать тип значков",
|
||||
"badges.menu.add_subscription": "Добавить подписку на значки",
|
||||
"badges.menu.remove_subscription": "Удалить подписку на значки",
|
||||
"badges.menu.open_list": "Достижения",
|
||||
"badges.menu.create_badge": "Создать достижение",
|
||||
"badges.menu.create_type": "Создать тип достижений",
|
||||
"badges.menu.add_subscription": "Добавить подписку на достижения",
|
||||
"badges.menu.remove_subscription": "Удалить подписку на достижения",
|
||||
|
||||
"badges.sidebar.title": "Значки",
|
||||
"badges.popover.title": "Значки",
|
||||
"badges.sidebar.title": "Достижения",
|
||||
"badges.popover.title": "Достижения",
|
||||
|
||||
"badges.admin.label": "Администратор достижений:",
|
||||
"badges.admin.placeholder": "имя пользователя",
|
||||
"badges.admin.help_text": "Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки."
|
||||
"badges.admin.label": "Администраторы достижений:",
|
||||
"badges.admin.placeholder": "Начните вводить имя...",
|
||||
"badges.admin.help_text": "Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.",
|
||||
"badges.admin.no_results": "Пользователь не найден",
|
||||
|
||||
"badges.rhs.create_badge": "+ Создать достижение",
|
||||
"badges.rhs.edit_badge": "Редактировать",
|
||||
"badges.rhs.types": "Типы",
|
||||
"badges.rhs.create_type": "+ Создать тип",
|
||||
|
||||
"badges.modal.create_badge_title": "Создать достижение",
|
||||
"badges.modal.edit_badge_title": "Редактировать достижение",
|
||||
"badges.modal.field_name": "Название",
|
||||
"badges.modal.field_name_placeholder": "Название достижения (макс. 20 символов)",
|
||||
"badges.modal.field_description": "Описание",
|
||||
"badges.modal.field_description_placeholder": "Описание достижения (макс. 120 символов)",
|
||||
"badges.modal.field_image": "Эмодзи",
|
||||
"badges.modal.field_image_placeholder": "Название эмодзи (напр. star)",
|
||||
"badges.modal.field_type": "Тип",
|
||||
"badges.modal.field_type_placeholder": "Выберите тип достижения",
|
||||
"badges.modal.field_multiple": "Можно выдавать несколько раз",
|
||||
"badges.modal.create_new_type": "+ Создать новый тип",
|
||||
"badges.modal.new_type_name": "Название типа",
|
||||
"badges.modal.new_type_name_placeholder": "Название типа (макс. 20 символов)",
|
||||
"badges.modal.new_type_everyone_create": "Все могут создавать достижения",
|
||||
"badges.modal.new_type_everyone_grant": "Все могут выдавать достижения",
|
||||
"badges.modal.btn_cancel": "Отмена",
|
||||
"badges.modal.btn_create": "Создать",
|
||||
"badges.modal.btn_save": "Сохранить",
|
||||
"badges.modal.btn_creating": "Сохранение...",
|
||||
"badges.modal.btn_delete": "Удалить достижение",
|
||||
"badges.modal.btn_confirm_delete": "Да, удалить",
|
||||
"badges.modal.confirm_delete": "Вы уверены?",
|
||||
"badges.modal.confirm_delete_badge": "Удалить достижение «{name}»?",
|
||||
"badges.modal.error_generic": "Произошла ошибка",
|
||||
"badges.modal.error_type_name_required": "Введите название типа",
|
||||
"badges.modal.error_type_required": "Выберите тип достижения",
|
||||
"badges.modal.error_duplicate_name": "Достижение в данном типе с таким названием уже существует",
|
||||
"badges.modal.error_not_found_emoji": "Этот эмодзи не найден",
|
||||
"badges.modal.create_type_title": "Создать тип",
|
||||
"badges.modal.edit_type_title": "Редактировать тип",
|
||||
"badges.modal.btn_delete_type": "Удалить тип",
|
||||
"badges.modal.delete_type": "Удалить тип",
|
||||
"badges.modal.confirm_delete_type": "Удалить тип «{name}»?",
|
||||
"badges.modal.btn_confirm_delete_type": "Да, удалить",
|
||||
|
||||
"badges.types.badge_count": "{count, plural, one {# достижение} few {# достижения} many {# достижений} other {# достижений}}",
|
||||
"badges.types.everyone_can_create": "Все создают",
|
||||
"badges.types.everyone_can_grant": "Все выдают",
|
||||
"badges.types.is_default": "По умолчанию",
|
||||
"badges.types.confirm_delete": "Удалить тип «{name}» и все его достижения?",
|
||||
"badges.types.empty": "Типов пока нет",
|
||||
"badges.types.no_badges": "В этом типе нет достижений",
|
||||
"badges.rhs.back_to_types": "Назад к типам",
|
||||
"badges.rhs.back_to_achievements": "Назад к достижениям",
|
||||
|
||||
"badges.modal.allowlist_create": "Список допущенных к созданию",
|
||||
"badges.modal.allowlist_create_help": "Пользователи, которые могут создавать достижения этого типа.",
|
||||
"badges.modal.allowlist_grant": "Список допущенных к выдаче",
|
||||
"badges.modal.allowlist_grant_help": "Пользователи, которые могут выдавать достижения этого типа.",
|
||||
"badges.modal.allowlist_placeholder": "user-1, user-2, user-3",
|
||||
|
||||
"badges.grant.title": "Выдать достижение",
|
||||
"badges.grant.intro": "Выдать достижение пользователю @{username}",
|
||||
"badges.grant.field_badge": "Достижение",
|
||||
"badges.grant.field_badge_placeholder": "Выберите достижение",
|
||||
"badges.grant.no_badges": "Нет доступных достижений",
|
||||
"badges.grant.field_reason": "Причина",
|
||||
"badges.grant.field_reason_placeholder": "За что выдаётся достижение? (необязательно)",
|
||||
"badges.grant.notify_here": "Уведомить в канале",
|
||||
"badges.grant.btn_grant": "Выдать",
|
||||
|
||||
"badges.revoke.btn": "Снять достижение",
|
||||
"badges.revoke.confirm": "Снять достижение?",
|
||||
"badges.revoke.confirm_yes": "Да",
|
||||
|
||||
"badges.subscription.title_create": "Добавить подписку",
|
||||
"badges.subscription.title_delete": "Удалить подписку",
|
||||
"badges.subscription.field_type": "Тип достижений",
|
||||
"badges.subscription.field_type_placeholder": "Выберите тип достижений",
|
||||
"badges.subscription.no_types": "Нет доступных типов",
|
||||
"badges.subscription.btn_create": "Добавить",
|
||||
"badges.subscription.btn_delete": "Удалить",
|
||||
|
||||
"badges.error.invalid_badge_id": "Не указано достижение",
|
||||
"badges.error.invalid_user_id": "Не указан пользователь",
|
||||
"badges.error.no_permission_grant": "Недостаточно прав для выдачи этого достижения",
|
||||
"badges.error.cannot_grant_badge": "Не удалось выдать достижение",
|
||||
"badges.error.user_not_found": "Пользователь не найден",
|
||||
"badges.error.invalid_type_id": "Не указан тип достижений",
|
||||
"badges.error.no_permission_subscription": "Недостаточно прав для управления подписками",
|
||||
"badges.error.cannot_create_subscription": "Не удалось создать подписку",
|
||||
"badges.error.cannot_delete_subscription": "Не удалось удалить подписку",
|
||||
"badges.error.ownership_not_found": "Выдача не найдена",
|
||||
"badges.error.no_permission_revoke": "Недостаточно прав для снятия этого достижения",
|
||||
"badges.error.cannot_revoke": "Не удалось снять достижение",
|
||||
"badges.error.already_owned": "Это достижение уже выдано этому пользователю",
|
||||
|
||||
"badges.error.unknown": "Произошла ошибка",
|
||||
"badges.error.cannot_get_user": "Не удалось получить данные пользователя",
|
||||
"badges.error.cannot_get_types": "Не удалось загрузить типы",
|
||||
"badges.error.cannot_get_badges": "Не удалось загрузить достижения",
|
||||
"badges.error.invalid_request": "Неверный формат запроса",
|
||||
"badges.error.invalid_name": "Необходимо указать название",
|
||||
"badges.error.invalid_image": "Необходимо указать эмодзи",
|
||||
"badges.error.type_not_found": "Тип достижения не найден",
|
||||
"badges.error.badge_not_found": "Достижение не найдено",
|
||||
"badges.error.no_permission": "Недостаточно прав для выполнения действия",
|
||||
"badges.error.missing_badge_id": "Не указан ID достижения",
|
||||
"badges.error.missing_type_id": "Не указан ID типа",
|
||||
"badges.error.cannot_create_badge": "Не удалось создать достижение",
|
||||
"badges.error.cannot_create_type": "Не удалось создать тип",
|
||||
"badges.error.cannot_update_badge": "Не удалось обновить достижение",
|
||||
"badges.error.cannot_delete_badge": "Не удалось удалить достижение",
|
||||
"badges.error.cannot_update_type": "Не удалось обновить тип",
|
||||
"badges.error.cannot_delete_type": "Не удалось удалить тип",
|
||||
|
||||
"emoji_picker.activities": "Мероприятия",
|
||||
"emoji_picker.animals-nature": "Животные и природа",
|
||||
"emoji_picker.close": "Закрыть",
|
||||
"emoji_picker.custom": "Настраиваемое",
|
||||
"emoji_picker.custom_emoji": "Пользовательские смайлики",
|
||||
"emoji_picker.emojiPicker.button.ariaLabel": "выберите смайлик",
|
||||
"emoji_picker.emojiPicker.previewPlaceholder": "Выберите смайлик",
|
||||
"emoji_picker.flags": "Флаги",
|
||||
"emoji_picker.food-drink": "Еда и напитки",
|
||||
"emoji_picker.header": "Выбор смайликов",
|
||||
"emoji_picker.objects": "Объекты",
|
||||
"emoji_picker.people-body": "Люди и тело",
|
||||
"emoji_picker.recent": "Недавно использованные",
|
||||
"emoji_picker.search": "Поиск смайликов",
|
||||
"emoji_picker.searchResults": "Результаты поиска",
|
||||
"emoji_picker.search_emoji": "Поиск смайлика",
|
||||
"emoji_picker.skin_tone": "Цвет кожи",
|
||||
"emoji_picker.smileys-emotion": "Смайлы и эмоции",
|
||||
"emoji_picker.symbols": "Символы",
|
||||
"emoji_picker.travel-places": "Места путешествий",
|
||||
"emoji_picker_item.emoji_aria_label": "смайлик {emojiName}"
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
"@typescript-eslint/parser": "4.22.0",
|
||||
"babel-eslint": "10.1.0",
|
||||
"babel-jest": "26.6.3",
|
||||
"babel-loader": "8.2.2",
|
||||
"babel-loader": "^8.3.0",
|
||||
"babel-plugin-typescript-to-proptypes": "1.4.2",
|
||||
"css-loader": "5.2.4",
|
||||
"enzyme": "3.11.0",
|
||||
@ -57,12 +57,11 @@
|
||||
"jest": "26.6.3",
|
||||
"jest-canvas-mock": "2.3.1",
|
||||
"jest-junit": "12.0.0",
|
||||
"loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz",
|
||||
"react-intl": "6.8.9",
|
||||
"sass": "1.86.0",
|
||||
"sass-loader": "11.0.1",
|
||||
"style-loader": "2.0.0",
|
||||
"webpack": "5.34.0",
|
||||
"webpack": "^5.54.0",
|
||||
"webpack-cli": "4.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -73,9 +72,13 @@
|
||||
"react": "17.0.2",
|
||||
"react-custom-scrollbars": "^4.2.1",
|
||||
"react-redux": "7.2.3",
|
||||
"react-virtuoso": "^4.18.1",
|
||||
"redux": "4.0.5",
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "17.0.3"
|
||||
},
|
||||
"jest": {
|
||||
"snapshotSerializers": [
|
||||
"<rootDir>/node_modules/enzyme-to-json/serializer"
|
||||
|
||||
@ -8,4 +8,17 @@ export default {
|
||||
RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view',
|
||||
RECEIVED_RHS_USER: pluginId + '_received_rhs_user',
|
||||
RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge',
|
||||
RECEIVED_RHS_TYPE: pluginId + '_received_rhs_type',
|
||||
OPEN_CREATE_BADGE_MODAL: pluginId + '_open_create_badge_modal',
|
||||
CLOSE_CREATE_BADGE_MODAL: pluginId + '_close_create_badge_modal',
|
||||
OPEN_EDIT_BADGE_MODAL: pluginId + '_open_edit_badge_modal',
|
||||
CLOSE_EDIT_BADGE_MODAL: pluginId + '_close_edit_badge_modal',
|
||||
OPEN_CREATE_TYPE_MODAL: pluginId + '_open_create_type_modal',
|
||||
CLOSE_CREATE_TYPE_MODAL: pluginId + '_close_create_type_modal',
|
||||
OPEN_EDIT_TYPE_MODAL: pluginId + '_open_edit_type_modal',
|
||||
CLOSE_EDIT_TYPE_MODAL: pluginId + '_close_edit_type_modal',
|
||||
OPEN_GRANT_MODAL: pluginId + '_open_grant_modal',
|
||||
CLOSE_GRANT_MODAL: pluginId + '_close_grant_modal',
|
||||
OPEN_SUBSCRIPTION_MODAL: pluginId + '_open_subscription_modal',
|
||||
CLOSE_SUBSCRIPTION_MODAL: pluginId + '_close_subscription_modal',
|
||||
};
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
import {AnyAction, Dispatch} from 'redux';
|
||||
|
||||
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||
import {GetStateFunc} from 'mattermost-redux/types/actions';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {IntegrationTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import ActionTypes from 'action_types/';
|
||||
import {BadgeID} from 'types/badges';
|
||||
import {RHSState} from 'types/general';
|
||||
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from 'types/badges';
|
||||
import {GrantModalData, RHSState, SubscriptionModalData} from 'types/general';
|
||||
import {id as pluginId} from '../manifest';
|
||||
|
||||
/**
|
||||
* Stores`showRHSPlugin` action returned by
|
||||
@ -36,91 +31,106 @@ export function setRHSBadge(badgeID: BadgeID | null) {
|
||||
}
|
||||
|
||||
export function setRHSView(view: RHSState) {
|
||||
return {
|
||||
type: ActionTypes.RECEIVED_RHS_VIEW,
|
||||
data: view,
|
||||
return (dispatch: Dispatch<AnyAction>, getState: () => any) => {
|
||||
const state = getState();
|
||||
const pluginState = state['plugins-' + pluginId];
|
||||
const currentView = pluginState?.rhsView;
|
||||
dispatch({
|
||||
type: ActionTypes.RECEIVED_RHS_VIEW,
|
||||
data: view,
|
||||
prevView: currentView,
|
||||
});
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function setTriggerId(triggerId: string) {
|
||||
export function setRHSType(typeId: number | null, typeName: string | null) {
|
||||
return {
|
||||
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
|
||||
data: triggerId,
|
||||
type: ActionTypes.RECEIVED_RHS_TYPE,
|
||||
data: {typeId, typeName},
|
||||
};
|
||||
}
|
||||
|
||||
export function openGrant(user?: string, badge?: string) {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
let command = '/badges grant';
|
||||
if (user) {
|
||||
command += ` --user ${user}`;
|
||||
}
|
||||
|
||||
if (badge) {
|
||||
command += ` --badge ${badge}`;
|
||||
}
|
||||
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openGrantModal({prefillUser: user, prefillBadgeId: badge}));
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openCreateType() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges create type';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openCreateTypeModal());
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openCreateBadge() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges create badge';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openCreateBadgeModal());
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openAddSubscription() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges subscription create';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
export function openCreateBadgeModal() {
|
||||
return {type: ActionTypes.OPEN_CREATE_BADGE_MODAL};
|
||||
}
|
||||
|
||||
export function closeCreateBadgeModal() {
|
||||
return {type: ActionTypes.CLOSE_CREATE_BADGE_MODAL};
|
||||
}
|
||||
|
||||
export function openEditBadgeModal(badge: BadgeDetails) {
|
||||
return {type: ActionTypes.OPEN_EDIT_BADGE_MODAL, data: badge};
|
||||
}
|
||||
|
||||
export function closeEditBadgeModal() {
|
||||
return {type: ActionTypes.CLOSE_EDIT_BADGE_MODAL};
|
||||
}
|
||||
|
||||
export function openCreateTypeModal() {
|
||||
return {type: ActionTypes.OPEN_CREATE_TYPE_MODAL};
|
||||
}
|
||||
|
||||
export function closeCreateTypeModal() {
|
||||
return {type: ActionTypes.CLOSE_CREATE_TYPE_MODAL};
|
||||
}
|
||||
|
||||
export function openEditTypeModal(badgeType: BadgeTypeDefinition) {
|
||||
return {type: ActionTypes.OPEN_EDIT_TYPE_MODAL, data: badgeType};
|
||||
}
|
||||
|
||||
export function closeEditTypeModal() {
|
||||
return {type: ActionTypes.CLOSE_EDIT_TYPE_MODAL};
|
||||
}
|
||||
|
||||
export function openGrantModal(data?: GrantModalData) {
|
||||
return {type: ActionTypes.OPEN_GRANT_MODAL, data: data || {}};
|
||||
}
|
||||
|
||||
export function closeGrantModal() {
|
||||
return {type: ActionTypes.CLOSE_GRANT_MODAL};
|
||||
}
|
||||
|
||||
export function openSubscriptionModal(data: SubscriptionModalData) {
|
||||
return {type: ActionTypes.OPEN_SUBSCRIPTION_MODAL, data};
|
||||
}
|
||||
|
||||
export function closeSubscriptionModal() {
|
||||
return {type: ActionTypes.CLOSE_SUBSCRIPTION_MODAL};
|
||||
}
|
||||
|
||||
export function openAddSubscription() {
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openSubscriptionModal({mode: 'create'}));
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export function openRemoveSubscription() {
|
||||
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||
const command = '/badges subscription remove';
|
||||
clientExecuteCommand(dispatch, getState, command);
|
||||
|
||||
return (dispatch: Dispatch<AnyAction>) => {
|
||||
dispatch(openSubscriptionModal({mode: 'delete'}));
|
||||
return {data: true};
|
||||
};
|
||||
}
|
||||
|
||||
export async function clientExecuteCommand(dispatch: Dispatch<AnyAction>, getState: GetStateFunc, command: string) {
|
||||
let currentChannel = getCurrentChannel(getState());
|
||||
const currentTeamId = getCurrentTeamId(getState());
|
||||
|
||||
// Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded)
|
||||
if (!currentChannel) {
|
||||
currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square');
|
||||
}
|
||||
|
||||
const args = {
|
||||
channel_id: currentChannel?.id,
|
||||
team_id: currentTeamId,
|
||||
};
|
||||
|
||||
try {
|
||||
//@ts-ignore Typing in mattermost-redux is wrong
|
||||
const data = await Client4.executeCommand(command, args);
|
||||
dispatch(setTriggerId(data?.trigger_id));
|
||||
} catch (error) {
|
||||
console.error(error); //eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import {Client4} from 'mattermost-redux/client';
|
||||
import {ClientError} from 'mattermost-redux/client/client4';
|
||||
|
||||
import manifest from 'manifest';
|
||||
import {AllBadgesBadge, BadgeDetails, BadgeID, UserBadge} from 'types/badges';
|
||||
import {AllBadgesBadge, Badge, BadgeDetails, BadgeID, BadgeTypeDefinition, CreateBadgeRequest, CreateTypeRequest, GetTypesResponse, GrantBadgeRequest, RevokeOwnershipRequest, SubscriptionRequest, UpdateBadgeRequest, UpdateTypeRequest, UserBadge} from 'types/badges';
|
||||
|
||||
export default class Client {
|
||||
private url: string;
|
||||
@ -41,6 +41,64 @@ export default class Client {
|
||||
}
|
||||
}
|
||||
|
||||
async getTypes(): Promise<GetTypesResponse> {
|
||||
try {
|
||||
const res = await this.doGet(`${this.url}/getTypes`);
|
||||
return res as GetTypesResponse;
|
||||
} catch {
|
||||
return {types: [], can_create_type: false, can_edit_type: false};
|
||||
}
|
||||
}
|
||||
|
||||
async createBadge(req: CreateBadgeRequest): Promise<Badge> {
|
||||
return await this.doPost(`${this.url}/createBadge`, req) as Badge;
|
||||
}
|
||||
|
||||
async createType(req: CreateTypeRequest): Promise<BadgeTypeDefinition> {
|
||||
return await this.doPost(`${this.url}/createType`, req) as BadgeTypeDefinition;
|
||||
}
|
||||
|
||||
async updateBadge(req: UpdateBadgeRequest): Promise<Badge> {
|
||||
return await this.doPut(`${this.url}/updateBadge`, req) as Badge;
|
||||
}
|
||||
|
||||
async deleteBadge(badgeID: BadgeID): Promise<void> {
|
||||
await this.doDelete(`${this.url}/deleteBadge/${badgeID}`);
|
||||
}
|
||||
|
||||
async updateType(req: UpdateTypeRequest): Promise<BadgeTypeDefinition> {
|
||||
return await this.doPut(`${this.url}/updateType`, req) as BadgeTypeDefinition;
|
||||
}
|
||||
|
||||
async deleteType(typeID: string): Promise<void> {
|
||||
await this.doDelete(`${this.url}/deleteType/${typeID}`);
|
||||
}
|
||||
|
||||
async grantBadge(req: GrantBadgeRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/grantBadge`, req);
|
||||
}
|
||||
|
||||
async createSubscription(req: SubscriptionRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/createSubscription`, req);
|
||||
}
|
||||
|
||||
async deleteSubscription(req: SubscriptionRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/deleteSubscription`, req);
|
||||
}
|
||||
|
||||
async revokeOwnership(req: RevokeOwnershipRequest): Promise<void> {
|
||||
await this.doPost(`${this.url}/revokeOwnership`, req);
|
||||
}
|
||||
|
||||
async getChannelSubscriptions(channelID: string): Promise<BadgeTypeDefinition[]> {
|
||||
try {
|
||||
const res = await this.doGet(`${this.url}/getChannelSubscriptions/${channelID}`);
|
||||
return res as BadgeTypeDefinition[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private doGet = async (url: string, headers: {[x:string]: string} = {}) => {
|
||||
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
|
||||
|
||||
@ -63,4 +121,75 @@ export default class Client {
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
private doPost = async (url: string, body: any, headers: {[x:string]: string} = {}) => {
|
||||
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
|
||||
|
||||
const options = {
|
||||
method: 'post',
|
||||
body: JSON.stringify(body),
|
||||
headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, Client4.getOptions(options));
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
throw new ClientError(Client4.url, {
|
||||
message: text || '',
|
||||
status_code: response.status,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
private doPut = async (url: string, body: any, headers: {[x:string]: string} = {}) => {
|
||||
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
|
||||
|
||||
const options = {
|
||||
method: 'put',
|
||||
body: JSON.stringify(body),
|
||||
headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, Client4.getOptions(options));
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
throw new ClientError(Client4.url, {
|
||||
message: text || '',
|
||||
status_code: response.status,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
private doDelete = async (url: string, headers: {[x:string]: string} = {}) => {
|
||||
|
|
||||
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
|
||||
|
||||
const options = {
|
||||
method: 'delete',
|
||||
headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, Client4.getOptions(options));
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
throw new ClientError(Client4.url, {
|
||||
message: text || '',
|
||||
status_code: response.status,
|
||||
url,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import UserMultiSelect from 'components/user_multi_select';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
@ -16,10 +17,8 @@ type Props = {
|
||||
}
|
||||
|
||||
const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, setSaveNeeded}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(id, e.target.value);
|
||||
const handleChange = useCallback((newValue: string) => {
|
||||
onChange(id, newValue);
|
||||
setSaveNeeded();
|
||||
}, [id, onChange, setSaveNeeded]);
|
||||
|
||||
@ -28,25 +27,19 @@ const BadgesAdminSetting: React.FC<Props> = ({id, value, disabled, onChange, set
|
||||
<label className='control-label col-sm-4'>
|
||||
<FormattedMessage
|
||||
id='badges.admin.label'
|
||||
defaultMessage='Администратор достижений:'
|
||||
defaultMessage='Администраторы достижений:'
|
||||
/>
|
||||
</label>
|
||||
<div className='col-sm-8'>
|
||||
<input
|
||||
className='form-control'
|
||||
type='text'
|
||||
value={value || ''}
|
||||
disabled={disabled}
|
||||
<UserMultiSelect
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'badges.admin.placeholder',
|
||||
defaultMessage: 'username',
|
||||
})}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className='help-text'>
|
||||
<FormattedMessage
|
||||
id='badges.admin.help_text'
|
||||
defaultMessage='Этот пользователь будет считаться администратором плагина достижений. Он может создавать типы, а также изменять и выдавать любые значки.'
|
||||
defaultMessage='Эти пользователи будут считаться администраторами плагина достижений. Они могут создавать типы, а также изменять и выдавать любые достижения.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
13
webapp/src/components/back_button/back_button.scss
Normal file
@ -0,0 +1,13 @@
|
||||
.BackButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
29
webapp/src/components/back_button/back_button.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import {RHSState} from '../../types/general';
|
||||
|
||||
import './back_button.scss';
|
||||
|
||||
type Props = {
|
||||
targetView: RHSState;
|
||||
onNavigate: (view: RHSState) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const BackButton: React.FC<Props> = ({
|
||||
targetView,
|
||||
onNavigate,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className='BackButton'
|
||||
onClick={() => onNavigate(targetView)}
|
||||
>
|
||||
{'← '}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackButton;
|
||||
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import {Badge} from '../../types/badges';
|
||||
import RenderEmoji from '../utils/emoji';
|
||||
import RenderEmoji from '../emoji/emoji';
|
||||
import {IMAGE_TYPE_ABSOLUTE_URL, IMAGE_TYPE_EMOJI} from '../../constants';
|
||||
|
||||
type Props = {
|
||||
422
webapp/src/components/badge_modal/badge_modal.scss
Normal file
@ -0,0 +1,422 @@
|
||||
@keyframes badgeModalBackdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes badgeModalBackdropOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes badgeModalDialogIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes badgeModalDialogOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-40px);
|
||||
}
|
||||
}
|
||||
|
||||
.BadgeModal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
animation: badgeModalBackdropIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
&__dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 32px rgba(0, 0, 0, 0.12);
|
||||
width: 480px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: badgeModalDialogIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
&--closing {
|
||||
.BadgeModal__backdrop {
|
||||
animation: badgeModalBackdropOut 0.15s ease-in forwards;
|
||||
}
|
||||
|
||||
.BadgeModal__dialog {
|
||||
animation: badgeModalDialogOut 0.15s ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
&--compact {
|
||||
.BadgeModal__body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.BadgeModal__dialog {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px 0;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
opacity: 0.56;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__body {
|
||||
padding: 20px 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grant-intro {
|
||||
font-size: 14px;
|
||||
margin: 0 0 16px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.64;
|
||||
|
||||
.required {
|
||||
color: var(--error-text, #d24b4e);
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
> input[type='text'],
|
||||
> select,
|
||||
> textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--button-bg, #166de0);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
> textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.emoji-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--button-bg, #166de0);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
.emoticon {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.emojisprite,
|
||||
.emoticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 12px 8px 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
label {
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-type-section {
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
border: 1px dashed rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.24);
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.04);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
&--primary {
|
||||
background: var(--button-bg, #166de0);
|
||||
color: var(--button-color, #fff);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&--cancel {
|
||||
background: transparent;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: var(--error-text, #d24b4e);
|
||||
color: #fff;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-text, #d24b4e);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.delete-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.confirm-delete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--error-text, #d24b4e);
|
||||
}
|
||||
}
|
||||
|
||||
.type-select {
|
||||
position: relative;
|
||||
|
||||
&__trigger {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32);
|
||||
}
|
||||
}
|
||||
|
||||
&__value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
font-size: 12px;
|
||||
opacity: 0.56;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
z-index: 10;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--create {
|
||||
border-top: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
color: var(--button-bg, #166de0);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__option-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
opacity: 0.4;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--error-text, #d24b4e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
25
webapp/src/components/badge_modal/emoji_picker.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
interface EmojiPickerOverlayProps {
|
||||
target: () => HTMLElement | null;
|
||||
container?: () => HTMLElement | null;
|
||||
show: boolean;
|
||||
onHide: () => void;
|
||||
onEmojiClick: (emoji: any) => void;
|
||||
rightOffset?: number;
|
||||
defaultHorizontalPosition?: 'left' | 'right';
|
||||
onExited?: () => void;
|
||||
hideCustomEmojiButton?: boolean;
|
||||
}
|
||||
|
||||
const EmojiPickerOverlay: React.FC<EmojiPickerOverlayProps> = (props) => {
|
||||
const Overlay = (window as any).Components?.EmojiPickerOverlay;
|
||||
|
||||
if (!Overlay) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Overlay {...props}/>;
|
||||
};
|
||||
|
||||
export default EmojiPickerOverlay;
|
||||
470
webapp/src/components/badge_modal/index.tsx
Normal file
@ -0,0 +1,470 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
|
||||
import RenderEmoji from 'components/emoji/emoji';
|
||||
|
||||
import {isCreateBadgeModalVisible, getEditBadgeModalData, getEmojiMap} from 'selectors';
|
||||
import {closeCreateBadgeModal, closeEditBadgeModal, setRHSView} from 'actions/actions';
|
||||
import {RHS_STATE_ALL} from '../../constants';
|
||||
import {BadgeFormData, BadgeTypeDefinition, TypeFormData} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import {getServerErrorId} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
import EmojiIcon from 'components/icons/emoji_icon';
|
||||
|
||||
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
|
||||
|
||||
import EmojiPickerOverlay from './emoji_picker';
|
||||
import InlineTypeForm from './inline_type_form';
|
||||
import TypeSelect from './type_select';
|
||||
|
||||
import './badge_modal.scss';
|
||||
|
||||
const NEW_TYPE_VALUE = '__new__';
|
||||
|
||||
const emptyBadgeForm: BadgeFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
image: '',
|
||||
badgeType: '',
|
||||
multiple: false,
|
||||
};
|
||||
|
||||
const emptyTypeForm: TypeFormData = {
|
||||
name: '',
|
||||
everyoneCanCreate: false,
|
||||
everyoneCanGrant: false,
|
||||
allowlistCanCreate: '',
|
||||
allowlistCanGrant: '',
|
||||
};
|
||||
|
||||
const BadgeModal: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const createVisible = useSelector(isCreateBadgeModalVisible);
|
||||
const editData = useSelector(getEditBadgeModalData);
|
||||
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
|
||||
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
|
||||
const isOpen = createVisible || editData !== null;
|
||||
const isEditMode = editData !== null;
|
||||
|
||||
const [form, setForm] = useState<BadgeFormData>(emptyBadgeForm);
|
||||
const [newTypeForm, setNewTypeForm] = useState<TypeFormData>(emptyTypeForm);
|
||||
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
|
||||
const [showCreateType, setShowCreateType] = useState(false);
|
||||
const [canCreateType, setCanCreateType] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [confirmDeleteTypeId, setConfirmDeleteTypeId] = useState<string | null>(null);
|
||||
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateForm = useCallback((updates: Partial<BadgeFormData>) => {
|
||||
setForm((prev) => ({...prev, ...updates}));
|
||||
}, []);
|
||||
|
||||
const updateTypeForm = useCallback((updates: Partial<TypeFormData>) => {
|
||||
setNewTypeForm((prev) => ({...prev, ...updates}));
|
||||
}, []);
|
||||
|
||||
const emojiData = (window as any)?.useGetEmojiSelectorData?.();
|
||||
const {
|
||||
emojiButtonRef,
|
||||
calculateRightOffSet,
|
||||
} = emojiData || {};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const fetchTypes = async () => {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос? может тогда лучше сделать client синглтоном прямо в файле api? Чтобы сразу экспортировать и не делать new Client когда нужен запрос?
|
||||
const resp = await client.getTypes();
|
||||
setTypes(resp.types);
|
||||
setCanCreateType(resp.can_create_type);
|
||||
if (!isEditMode && resp.types.length > 0) {
|
||||
const defaultType = resp.types.find((t) => t.is_default);
|
||||
setForm((prev) => ({...prev, badgeType: String(defaultType ? defaultType.id : resp.types[0].id)}));
|
||||
}
|
||||
};
|
||||
fetchTypes();
|
||||
|
||||
if (isEditMode && editData) {
|
||||
setForm({
|
||||
name: editData.name,
|
||||
description: editData.description,
|
||||
image: editData.image,
|
||||
badgeType: String(editData.type),
|
||||
multiple: editData.multiple,
|
||||
});
|
||||
} else {
|
||||
setForm(emptyBadgeForm);
|
||||
}
|
||||
setShowCreateType(false);
|
||||
setNewTypeForm(emptyTypeForm);
|
||||
setError(null);
|
||||
setConfirmDelete(false);
|
||||
setConfirmDeleteTypeId(null);
|
||||
setTypeDropdownOpen(false);
|
||||
setShowEmojiPicker(false);
|
||||
setLoading(false);
|
||||
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
if (createVisible) {
|
||||
dispatch(closeCreateBadgeModal());
|
||||
}
|
||||
if (editData) {
|
||||
dispatch(closeEditBadgeModal());
|
||||
}
|
||||
setClosing(false);
|
||||
}, [dispatch, createVisible, editData]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
}, [doClose]);
|
||||
|
||||
const handleTypeSelect = useCallback((val: string) => {
|
||||
if (val === NEW_TYPE_VALUE) {
|
||||
setShowCreateType(true);
|
||||
updateForm({badgeType: ''});
|
||||
} else {
|
||||
setShowCreateType(false);
|
||||
updateForm({badgeType: val});
|
||||
}
|
||||
setTypeDropdownOpen(false);
|
||||
setConfirmDeleteTypeId(null);
|
||||
}, [updateForm]);
|
||||
|
||||
const handleEmojiSelect = (emoji: any) => {
|
||||
if (emoji.short_name) {
|
||||
updateForm({image: emoji.short_name});
|
||||
} else if (emoji.name) {
|
||||
updateForm({image: emoji.name});
|
||||
}
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleDeleteType = useCallback(async (typeId: string) => {
|
||||
if (confirmDeleteTypeId !== typeId) {
|
||||
setConfirmDeleteTypeId(typeId);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
идентично идентично
|
||||
await client.deleteType(typeId);
|
||||
const removeById = (t: BadgeTypeDefinition) => String(t.id) !== typeId;
|
||||
setTypes((prev) => prev.filter(removeById));
|
||||
if (form.badgeType === typeId) {
|
||||
updateForm({badgeType: ''});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
}
|
||||
setConfirmDeleteTypeId(null);
|
||||
}, [confirmDeleteTypeId, form.badgeType, updateForm, intl]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
let typeID = form.badgeType;
|
||||
if (showCreateType) {
|
||||
if (!newTypeForm.name.trim()) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_type_name_required', defaultMessage: 'Введите название типа'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const createdType = await client.createType({
|
||||
name: newTypeForm.name.trim(),
|
||||
everyone_can_create: newTypeForm.everyoneCanCreate,
|
||||
everyone_can_grant: newTypeForm.everyoneCanGrant,
|
||||
allowlist_can_create: newTypeForm.allowlistCanCreate.trim(),
|
||||
allowlist_can_grant: newTypeForm.allowlistCanGrant.trim(),
|
||||
channel_id: channelId,
|
||||
});
|
||||
typeID = String(createdType.id);
|
||||
}
|
||||
if (!typeID) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_type_required', defaultMessage: 'Выберите тип достижения'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const allBadges = await client.getAllBadges();
|
||||
const trimmedName = form.name.trim().toLowerCase();
|
||||
const duplicate = allBadges.find(
|
||||
(b) => b.name.toLowerCase() === trimmedName &&
|
||||
String(b.type) === typeID &&
|
||||
(!isEditMode || !editData || b.id !== editData.id),
|
||||
);
|
||||
if (!emojiMap.has(form.image)) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_not_found_emoji', defaultMessage: 'Этот эмодзи не найден'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (duplicate) {
|
||||
setError(intl.formatMessage({id: 'badges.modal.error_duplicate_name', defaultMessage: 'Достижение в данном типе с таким названием уже существует'}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (isEditMode && editData) {
|
||||
await client.updateBadge({
|
||||
id: String(editData.id),
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
image: form.image.trim(),
|
||||
type: typeID,
|
||||
multiple: form.multiple,
|
||||
});
|
||||
} else {
|
||||
await client.createBadge({
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
image: form.image.trim(),
|
||||
type: typeID,
|
||||
multiple: form.multiple,
|
||||
channel_id: channelId,
|
||||
});
|
||||
}
|
||||
handleClose();
|
||||
dispatch(setRHSView(RHS_STATE_ALL));
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, showCreateType, newTypeForm, isEditMode, editData, handleClose, intl, channelId, dispatch, emojiMap]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!editData) {
|
||||
return;
|
||||
}
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
await client.deleteBadge(editData.id);
|
||||
handleClose();
|
||||
dispatch(setRHSView(RHS_STATE_ALL));
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [editData, confirmDelete, handleClose, intl, dispatch]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = isEditMode
|
||||
? intl.formatMessage({id: 'badges.modal.edit_badge_title', defaultMessage: 'Редактировать достижение'})
|
||||
: intl.formatMessage({id: 'badges.modal.create_badge_title', defaultMessage: 'Создать достижение'});
|
||||
const submitLabel = isEditMode
|
||||
? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'})
|
||||
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}
|
||||
ref={modalRef}
|
||||
>
|
||||
<div
|
||||
className='BadgeModal__backdrop'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div
|
||||
className='BadgeModal__dialog'
|
||||
ref={dialogRef}
|
||||
>
|
||||
<div className='BadgeModal__header'>
|
||||
<h4>{title}</h4>
|
||||
<button
|
||||
className='close-btn'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</button>
|
||||
</div>
|
||||
<div className='BadgeModal__body'>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_name'
|
||||
defaultMessage='Название'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm({name: e.target.value})}
|
||||
maxLength={20}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.field_name_placeholder', defaultMessage: 'Название достижения (макс. 20 символов)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_description'
|
||||
defaultMessage='Описание'
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) => updateForm({description: e.target.value})}
|
||||
maxLength={120}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.field_description_placeholder', defaultMessage: 'Описание достижения (макс. 120 символов)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_image'
|
||||
defaultMessage='Эмодзи'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<div className='emoji-input'>
|
||||
<button
|
||||
type='button'
|
||||
className='emoji-input__icon'
|
||||
onClick={() => setShowEmojiPicker((prev) => !prev)}
|
||||
ref={emojiButtonRef}
|
||||
>
|
||||
<EmojiIcon/>
|
||||
</button>
|
||||
{form.image && (
|
||||
<RenderEmoji
|
||||
emojiName={form.image}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type='text'
|
||||
value={form.image}
|
||||
onChange={(e) => updateForm({image: e.target.value.trim()})}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.field_image_placeholder', defaultMessage: 'Название эмодзи (напр. star)'})}
|
||||
/>
|
||||
</div>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPickerOverlay
|
||||
target={() => emojiButtonRef?.current}
|
||||
container={() => modalRef.current}
|
||||
show={showEmojiPicker}
|
||||
onHide={() => setShowEmojiPicker(false)}
|
||||
onEmojiClick={handleEmojiSelect}
|
||||
rightOffset={calculateRightOffSet?.(emojiButtonRef?.current)}
|
||||
defaultHorizontalPosition='right'
|
||||
hideCustomEmojiButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_type'
|
||||
defaultMessage='Тип'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<TypeSelect
|
||||
types={types}
|
||||
badgeType={form.badgeType}
|
||||
showCreateType={showCreateType}
|
||||
canCreateType={canCreateType}
|
||||
typeDropdownOpen={typeDropdownOpen}
|
||||
confirmDeleteTypeId={confirmDeleteTypeId}
|
||||
onToggleDropdown={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||
onSelect={handleTypeSelect}
|
||||
onDeleteType={handleDeleteType}
|
||||
onCancelDeleteType={() => setConfirmDeleteTypeId(null)}
|
||||
/>
|
||||
{showCreateType && (
|
||||
<InlineTypeForm
|
||||
form={newTypeForm}
|
||||
onChange={updateTypeForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='badgeMultiple'
|
||||
checked={form.multiple}
|
||||
onChange={(e) => updateForm({multiple: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='badgeMultiple'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_multiple'
|
||||
defaultMessage='Можно выдавать несколько раз'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
{isEditMode && (
|
||||
<div className='delete-section'>
|
||||
<button
|
||||
className='btn btn--danger'
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_delete'
|
||||
defaultMessage='Удалить достижение'
|
||||
/>
|
||||
</button>
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.confirm_delete_badge'
|
||||
defaultMessage='Удалить достижение «{name}»?'
|
||||
values={{name: form.name || editData?.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='BadgeModal__footer'>
|
||||
<button
|
||||
className='btn btn--cancel'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className='btn btn--primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !form.name.trim() || !form.image.trim()}
|
||||
>
|
||||
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeModal;
|
||||
94
webapp/src/components/badge_modal/inline_type_form.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {TypeFormData} from 'types/badges';
|
||||
import UserMultiSelect from 'components/user_multi_select';
|
||||
|
||||
type Props = {
|
||||
form: TypeFormData;
|
||||
onChange: (updates: Partial<TypeFormData>) => void;
|
||||
}
|
||||
|
||||
const InlineTypeForm: React.FC<Props> = ({form, onChange}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className='inline-type-section'>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.new_type_name'
|
||||
defaultMessage='Название типа'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={form.name}
|
||||
onChange={(e) => onChange({name: e.target.value})}
|
||||
maxLength={20}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='newTypeEveryoneCanCreate'
|
||||
checked={form.everyoneCanCreate}
|
||||
onChange={(e) => onChange({everyoneCanCreate: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='newTypeEveryoneCanCreate'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.new_type_everyone_create'
|
||||
defaultMessage='Все могут создавать достижения'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!form.everyoneCanCreate && (
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.allowlist_create'
|
||||
defaultMessage='Список допущенных к созданию'
|
||||
/>
|
||||
</label>
|
||||
<UserMultiSelect
|
||||
value={form.allowlistCanCreate}
|
||||
onChange={(v) => onChange({allowlistCanCreate: v})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='newTypeEveryoneCanGrant'
|
||||
checked={form.everyoneCanGrant}
|
||||
onChange={(e) => onChange({everyoneCanGrant: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='newTypeEveryoneCanGrant'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.new_type_everyone_grant'
|
||||
defaultMessage='Все могут выдавать достижения'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!form.everyoneCanGrant && (
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.allowlist_grant'
|
||||
defaultMessage='Список допущенных к выдаче'
|
||||
/>
|
||||
</label>
|
||||
<UserMultiSelect
|
||||
value={form.allowlistCanGrant}
|
||||
onChange={(v) => onChange({allowlistCanGrant: v})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineTypeForm;
|
||||
110
webapp/src/components/badge_modal/type_select.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {BadgeTypeDefinition} from 'types/badges';
|
||||
import TrashIcon from 'components/icons/trash_icon';
|
||||
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
|
||||
|
||||
const NEW_TYPE_VALUE = '__new__';
|
||||
|
||||
type Props = {
|
||||
types: BadgeTypeDefinition[];
|
||||
badgeType: string;
|
||||
showCreateType: boolean;
|
||||
canCreateType: boolean;
|
||||
typeDropdownOpen: boolean;
|
||||
confirmDeleteTypeId: string | null;
|
||||
onToggleDropdown: () => void;
|
||||
onSelect: (val: string) => void;
|
||||
onDeleteType: (typeId: string) => void;
|
||||
onCancelDeleteType: () => void;
|
||||
}
|
||||
|
||||
const TypeSelect: React.FC<Props> = ({
|
||||
types,
|
||||
badgeType,
|
||||
showCreateType,
|
||||
canCreateType,
|
||||
typeDropdownOpen,
|
||||
confirmDeleteTypeId,
|
||||
onToggleDropdown,
|
||||
onSelect,
|
||||
onDeleteType,
|
||||
onCancelDeleteType,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const selectedTypeName = types.find((t) => String(t.id) === badgeType)?.name ||
|
||||
|
vladimir.khablak
commented
мб в мемо? мб в мемо?
|
||||
intl.formatMessage({id: 'badges.modal.field_type_placeholder', defaultMessage: 'Выберите тип достижения'});
|
||||
const triggerLabel = showCreateType ? intl.formatMessage({id: 'badges.modal.create_new_type', defaultMessage: '+ Создать новый тип'}) : selectedTypeName;
|
||||
const confirmType = confirmDeleteTypeId ? types.find((t) => String(t.id) === confirmDeleteTypeId) : null;
|
||||
|
vladimir.khablak
commented
мб в мемо? мб в мемо?
|
||||
|
||||
return (
|
||||
<div className='type-select'>
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__trigger'
|
||||
onClick={onToggleDropdown}
|
||||
>
|
||||
<span className='type-select__value'>{triggerLabel}</span>
|
||||
<span className='type-select__arrow'>{'\u25BE'}</span>
|
||||
</button>
|
||||
{typeDropdownOpen && (
|
||||
<div className='type-select__dropdown'>
|
||||
{types.map((t) => {
|
||||
const tid = String(t.id);
|
||||
const isEmpty = t.badge_count === 0;
|
||||
return (
|
||||
<div
|
||||
key={tid}
|
||||
className={'type-select__option' + (tid === badgeType ? ' type-select__option--selected' : '')}
|
||||
>
|
||||
<span
|
||||
className='type-select__option-name'
|
||||
onClick={() => onSelect(tid)}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
{isEmpty && !t.is_default && (
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__delete-btn'
|
||||
onClick={() => onDeleteType(tid)}
|
||||
title={intl.formatMessage({id: 'badges.modal.delete_type', defaultMessage: 'Удалить тип'})}
|
||||
>
|
||||
<TrashIcon/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canCreateType && (
|
||||
<div
|
||||
className='type-select__option type-select__option--create'
|
||||
onClick={() => onSelect(NEW_TYPE_VALUE)}
|
||||
>
|
||||
<span className='type-select__option-name'>
|
||||
{intl.formatMessage({id: 'badges.modal.create_new_type', defaultMessage: '+ Создать новый тип'})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{confirmType && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => onDeleteType(String(confirmDeleteTypeId))}
|
||||
onCancel={onCancelDeleteType}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.confirm_delete_type'
|
||||
defaultMessage='Удалить тип «{name}»?'
|
||||
values={{name: confirmType.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeSelect;
|
||||
60
webapp/src/components/confirm_dialog/confirm_dialog.scss
Normal file
@ -0,0 +1,60 @@
|
||||
.ConfirmDialog {
|
||||
background: var(--center-channel-bg, #fff);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16);
|
||||
min-width: 240px;
|
||||
text-align: center;
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 11;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
.btn--cancel {
|
||||
background: var(--center-channel-bg, #fff);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.btn--danger {
|
||||
background: var(--error-text, #d24b4e);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--error-text, #d24b4e) 85%, #000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
webapp/src/components/confirm_dialog/confirm_dialog.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import './confirm_dialog.scss';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<Props> = ({children, onConfirm, onCancel}) => (
|
||||
<div
|
||||
className='ConfirmDialog__overlay'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='ConfirmDialog'>
|
||||
<p className='ConfirmDialog__text'>
|
||||
{children}
|
||||
</p>
|
||||
<div className='ConfirmDialog__actions'>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn--cancel'
|
||||
onClick={onCancel}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='btn btn--danger'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_confirm_delete'
|
||||
defaultMessage='Да, удалить'
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ConfirmDialog;
|
||||
@ -1,6 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React from 'react';
|
||||
import React, {memo} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
@ -14,16 +14,21 @@ interface ComponentProps {
|
||||
emojiStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
|
||||
const FALLBACK_EMOJI = 'question';
|
||||
|
||||
const RenderEmoji = ({emojiName, emojiStyle, size = 16}: ComponentProps) => {
|
||||
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
|
||||
|
||||
if (!emojiName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiFromMap = emojiMap.get(emojiName);
|
||||
let emojiFromMap = emojiMap.get(emojiName);
|
||||
if (!emojiFromMap) {
|
||||
return null;
|
||||
emojiFromMap = emojiMap.get(FALLBACK_EMOJI);
|
||||
if (!emojiFromMap) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
|
||||
|
||||
@ -46,10 +51,4 @@ const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
RenderEmoji.defaultProps = {
|
||||
emoji: '',
|
||||
emojiStyle: {},
|
||||
size: 16,
|
||||
};
|
||||
|
||||
export default React.memo(RenderEmoji);
|
||||
export default memo(RenderEmoji);
|
||||
292
webapp/src/components/grant_modal/index.tsx
Normal file
@ -0,0 +1,292 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {closeGrantModal} from 'actions/actions';
|
||||
import {getGrantModalData} from 'selectors';
|
||||
import {AllBadgesBadge} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import {getServerErrorId, getUserDisplayName} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
import RenderEmoji from 'components/emoji/emoji';
|
||||
|
||||
type GrantFormData = {
|
||||
badgeId: string;
|
||||
userId: string;
|
||||
userDisplayName: string;
|
||||
reason: string;
|
||||
notifyHere: boolean;
|
||||
}
|
||||
|
||||
const emptyForm: GrantFormData = {
|
||||
badgeId: '',
|
||||
userId: '',
|
||||
userDisplayName: '',
|
||||
reason: '',
|
||||
notifyHere: false,
|
||||
};
|
||||
|
||||
const GrantModal: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const modalData = useSelector(getGrantModalData);
|
||||
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
|
||||
const isOpen = modalData !== null;
|
||||
const hasFixedUser = Boolean(modalData?.prefillUser);
|
||||
|
||||
const [form, setForm] = useState<GrantFormData>(emptyForm);
|
||||
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
// Выбор достижения
|
||||
const [badgeDropdownOpen, setBadgeDropdownOpen] = useState(false);
|
||||
const badgeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateForm = useCallback((updates: Partial<GrantFormData>) => {
|
||||
setForm((prev) => ({...prev, ...updates}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Всегда очищаем форму при открытии
|
||||
setForm(emptyForm);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setBadgeDropdownOpen(false);
|
||||
|
||||
const fetchBadges = async () => {
|
||||
const client = new Client();
|
||||
const allBadges = await client.getAllBadges();
|
||||
setBadges(allBadges);
|
||||
};
|
||||
fetchBadges();
|
||||
|
||||
// Prefill достижения, если передан
|
||||
if (modalData?.prefillBadgeId) {
|
||||
setForm((prev) => ({...prev, badgeId: modalData.prefillBadgeId || ''}));
|
||||
}
|
||||
|
||||
// Prefill пользователя, если передан
|
||||
if (modalData?.prefillUser) {
|
||||
Client4.getUserByUsername(modalData.prefillUser).then((user) => {
|
||||
|
vladimir.khablak
commented
получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина получается что есть api а есть еще какое-то апи, на твое усмотрение можно запихнуть в api плагина
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
userId: user.id,
|
||||
userDisplayName: getUserDisplayName(user) || user.username,
|
||||
}));
|
||||
}).catch(() => {
|
||||
// Если пользователь не найден — игнорируем
|
||||
});
|
||||
}
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Закрытие выпадающих списков при клике снаружи
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (badgeDropdownRef.current && !badgeDropdownRef.current.contains(e.target as Node)) {
|
||||
setBadgeDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
dispatch(closeGrantModal());
|
||||
setClosing(false);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
|
vladimir.khablak
commented
на твое усмотрение - вынести число в константу на твое усмотрение - вынести число в константу
|
||||
}, [doClose]);
|
||||
|
||||
const handleBadgeSelect = (badgeId: string) => {
|
||||
updateForm({badgeId});
|
||||
setBadgeDropdownOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
await client.grantBadge({
|
||||
badge_id: form.badgeId,
|
||||
user_id: form.userId,
|
||||
reason: form.reason.trim(),
|
||||
notify_here: form.notifyHere,
|
||||
channel_id: channelId,
|
||||
});
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form, channelId, handleClose, intl]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedBadge = badges.find((b) => String(b.id) === form.badgeId);
|
||||
|
||||
return (
|
||||
<div className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}>
|
||||
<div
|
||||
className='BadgeModal__backdrop'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className='BadgeModal__dialog'>
|
||||
<div className='BadgeModal__header'>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='badges.grant.title'
|
||||
defaultMessage='Выдать достижение'
|
||||
/>
|
||||
</h4>
|
||||
<button
|
||||
className='close-btn'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</button>
|
||||
</div>
|
||||
<div className='BadgeModal__body'>
|
||||
{hasFixedUser && form.userDisplayName && (
|
||||
<p className='grant-intro'>
|
||||
<FormattedMessage
|
||||
id='badges.grant.intro'
|
||||
defaultMessage='Выдать достижение пользователю @{username}'
|
||||
values={{username: modalData?.prefillUser || ''}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.grant.field_badge'
|
||||
defaultMessage='Достижение'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<div
|
||||
className='type-select'
|
||||
ref={badgeDropdownRef}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__trigger'
|
||||
onClick={() => setBadgeDropdownOpen(!badgeDropdownOpen)}
|
||||
>
|
||||
<span className='type-select__value'>
|
||||
{selectedBadge ? (
|
||||
<>
|
||||
<RenderEmoji
|
||||
emojiName={selectedBadge.image}
|
||||
size={16}
|
||||
/>
|
||||
{' '}{selectedBadge.name}
|
||||
</>
|
||||
) : intl.formatMessage({id: 'badges.grant.field_badge_placeholder', defaultMessage: 'Выберите достижение'})}
|
||||
</span>
|
||||
<span className='type-select__arrow'>{'▾'}</span>
|
||||
</button>
|
||||
{badgeDropdownOpen && (
|
||||
<div className='type-select__dropdown'>
|
||||
{badges.length === 0 && (
|
||||
<div className='type-select__option'>
|
||||
<FormattedMessage
|
||||
id='badges.grant.no_badges'
|
||||
defaultMessage='Нет доступных достижений'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{badges.map((badge) => (
|
||||
<div
|
||||
key={badge.id}
|
||||
className={'type-select__option' + (String(badge.id) === form.badgeId ? ' type-select__option--selected' : '')}
|
||||
onClick={() => handleBadgeSelect(String(badge.id))}
|
||||
>
|
||||
<span className='type-select__option-name'>
|
||||
<RenderEmoji
|
||||
emojiName={badge.image}
|
||||
size={16}
|
||||
/>
|
||||
{' '}{badge.name}
|
||||
</span>
|
||||
<span style={{opacity: 0.56, fontSize: '12px'}}>{badge.type_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.grant.field_reason'
|
||||
defaultMessage='Причина'
|
||||
/>
|
||||
</label>
|
||||
<textarea
|
||||
value={form.reason}
|
||||
onChange={(e) => updateForm({reason: e.target.value})}
|
||||
maxLength={200}
|
||||
placeholder={intl.formatMessage({id: 'badges.grant.field_reason_placeholder', defaultMessage: 'За что выдаётся достижение? (необязательно)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='grantNotifyHere'
|
||||
checked={form.notifyHere}
|
||||
onChange={(e) => updateForm({notifyHere: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='grantNotifyHere'>
|
||||
<FormattedMessage
|
||||
id='badges.grant.notify_here'
|
||||
defaultMessage='Уведомить в канале'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
</div>
|
||||
<div className='BadgeModal__footer'>
|
||||
<button
|
||||
className='btn btn--cancel'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className='btn btn--primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !form.badgeId || !form.userId}
|
||||
>
|
||||
{loading
|
||||
? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'})
|
||||
: intl.formatMessage({id: 'badges.grant.btn_grant', defaultMessage: 'Выдать'})
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GrantModal;
|
||||
29
webapp/src/components/icons/close_icon.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const CloseIcon: React.FC<Props> = ({size = 16}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path
|
||||
stroke='none'
|
||||
d='M0 0h24v24H0z'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M18 6l-12 12'/>
|
||||
<path d='M6 6l12 12'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default CloseIcon;
|
||||
31
webapp/src/components/icons/emoji_icon.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const EmojiIcon: React.FC<Props> = ({size = 20}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path
|
||||
stroke='none'
|
||||
d='M0 0h24v24H0z'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0'/>
|
||||
<path d='M9 10l.01 0'/>
|
||||
<path d='M15 10l.01 0'/>
|
||||
<path d='M9.5 15a3.5 3.5 0 0 0 5 0'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default EmojiIcon;
|
||||
29
webapp/src/components/icons/search_icon.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const SearchIcon: React.FC<Props> = ({size = 18}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path
|
||||
stroke='none'
|
||||
d='M0 0h24v24H0z'
|
||||
fill='none'
|
||||
/>
|
||||
<path d='M3 10a7 7 0 1 0 14 0a7 7 0 1 0 -14 0'/>
|
||||
<path d='M21 21l-6 -6'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default SearchIcon;
|
||||
27
webapp/src/components/icons/trash_icon.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const TrashIcon: React.FC<Props> = ({size = 16}) => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
>
|
||||
<path d='M4 7l16 0'/>
|
||||
<path d='M10 11l0 6'/>
|
||||
<path d='M14 11l0 6'/>
|
||||
<path d='M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12'/>
|
||||
<path d='M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3'/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default TrashIcon;
|
||||
@ -3,4 +3,140 @@
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__loadingWrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__emptyContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__emptyTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__emptyDescription {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.72);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 4px 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--button-bg, #166de0);
|
||||
border-bottom-color: var(--button-bg, #166de0);
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
}
|
||||
|
||||
&__backHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__filterTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
> .badge-tooltip-wrapper {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 14px;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
&__fab {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--button-bg, #166de0);
|
||||
color: var(--button-color, #fff);
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,107 +1,154 @@
|
||||
import React from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {Virtuoso} from 'react-virtuoso';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {BadgeID, AllBadgesBadge} from '../../types/badges';
|
||||
import Client from '../../client/api';
|
||||
|
||||
import {RHSState} from '../../types/general';
|
||||
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
|
||||
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_TYPES} from '../../constants';
|
||||
import {isCreateBadgeModalVisible, getEditBadgeModalData} from '../../selectors';
|
||||
|
||||
import BackButton from 'components/back_button/back_button';
|
||||
|
||||
import AllBadgesRow from './all_badges_row';
|
||||
import RHSScrollbars from './rhs_scrollbars';
|
||||
|
||||
import './all_badges.scss';
|
||||
|
||||
type Props = {
|
||||
filterTypeId?: number | null;
|
||||
filterTypeName?: string | null;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => void;
|
||||
setRHSBadge: (badge: BadgeID | null) => void;
|
||||
getCustomEmojisByName: (names: string[]) => void;
|
||||
openCreateBadgeModal: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
type State = {
|
||||
loading: boolean;
|
||||
badges?: AllBadgesBadge[];
|
||||
}
|
||||
const AllBadges: React.FC<Props> = ({filterTypeId, filterTypeName, actions}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [badges, setBadges] = useState<AllBadgesBadge[]>([]);
|
||||
const isFiltered = filterTypeId != null;
|
||||
|
||||
class AllBadges extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const createBadgeVisible = useSelector(isCreateBadgeModalVisible);
|
||||
const editBadgeData = useSelector(getEditBadgeModalData);
|
||||
const isModalOpen = createBadgeVisible || editBadgeData !== null;
|
||||
const wasModalOpen = useRef(false);
|
||||
|
||||
this.state = {
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
const fetchBadges = useCallback(() => {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
идентично идентично
|
||||
client.getAllBadges().then((data) => {
|
||||
setBadges(data);
|
||||
setLoading(false);
|
||||
|
||||
componentDidMount() {
|
||||
const c = new Client();
|
||||
c.getAllBadges().then((badges) => {
|
||||
this.setState({badges, loading: false});
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.state.badges !== prevState.badges) {
|
||||
const names: string[] = [];
|
||||
this.state.badges?.forEach((badge) => {
|
||||
data.forEach((badge) => {
|
||||
if (badge.image_type === IMAGE_TYPE_EMOJI) {
|
||||
names.push(badge.image);
|
||||
}
|
||||
});
|
||||
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||
this.props.actions.getCustomEmojisByName(toLoad);
|
||||
}
|
||||
}
|
||||
|
||||
onBadgeClick = (badge: AllBadgesBadge) => {
|
||||
this.props.actions.setRHSBadge(badge.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.loading) {
|
||||
return (<div className='AllBadges'>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (!this.state.badges || this.state.badges.length === 0) {
|
||||
return (<div className='AllBadges'>
|
||||
<FormattedMessage
|
||||
id='badges.no_badges_yet'
|
||||
defaultMessage='Значков пока нет.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
const content = this.state.badges.map((badge) => {
|
||||
return (
|
||||
<AllBadgesRow
|
||||
key={badge.id}
|
||||
badge={badge}
|
||||
onClick={this.onBadgeClick}
|
||||
/>
|
||||
);
|
||||
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
|
||||
actions.getCustomEmojisByName(toLoad);
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchBadges();
|
||||
}, [fetchBadges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasModalOpen.current && !isModalOpen) {
|
||||
fetchBadges();
|
||||
}
|
||||
wasModalOpen.current = isModalOpen;
|
||||
}, [isModalOpen, fetchBadges]);
|
||||
|
||||
const displayBadges = useMemo(() => {
|
||||
if (!isFiltered) {
|
||||
return badges;
|
||||
}
|
||||
return badges.filter((b) => b.type === filterTypeId);
|
||||
}, [badges, isFiltered, filterTypeId]);
|
||||
|
||||
const onBadgeClick = useCallback((badge: AllBadgesBadge) => {
|
||||
actions.setRHSBadge(badge.id);
|
||||
actions.setRHSView(RHS_STATE_DETAIL);
|
||||
}, [actions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='AllBadges'>
|
||||
<div><b>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.all_badges'
|
||||
defaultMessage='Все значки'
|
||||
/>
|
||||
</b></div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
<div className='AllBadges__loadingWrap'>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = !isFiltered && (!badges || badges.length === 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFiltered && (
|
||||
<div className='AllBadges__header'>
|
||||
<div className='AllBadges__backHeader'>
|
||||
<BackButton
|
||||
targetView={RHS_STATE_TYPES}
|
||||
onNavigate={actions.setRHSView}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.back_to_types'
|
||||
defaultMessage='Назад к типам'
|
||||
/>
|
||||
</BackButton>
|
||||
<span className='AllBadges__filterTitle'>{filterTypeName}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className='AllBadges__emptyContent'>
|
||||
<div className='AllBadges__emptyTitle'>
|
||||
<FormattedMessage
|
||||
id='badges.empty.title'
|
||||
defaultMessage='Достижений пока нет'
|
||||
/>
|
||||
</div>
|
||||
<div className='AllBadges__emptyDescription'>
|
||||
<FormattedMessage
|
||||
id='badges.empty.description'
|
||||
defaultMessage='Создайте первое достижение, чтобы отмечать заслуги участников команды.'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && displayBadges.length === 0 && (
|
||||
<div className='AllBadges__empty'>
|
||||
<FormattedMessage
|
||||
id='badges.types.no_badges'
|
||||
defaultMessage='В этом типе нет достижений'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty && displayBadges.length > 0 && (
|
||||
<Virtuoso
|
||||
style={{flex: '1 1 auto'}}
|
||||
data={displayBadges}
|
||||
increaseViewportBy={300}
|
||||
overscan={200}
|
||||
itemContent={(_index, badge) => (
|
||||
<AllBadgesRow
|
||||
key={badge.id}
|
||||
badge={badge}
|
||||
onClick={onBadgeClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllBadges;
|
||||
|
||||
@ -1,25 +1,59 @@
|
||||
.AllBadgesRow {
|
||||
display: flex;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
margin-bottom: 3px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.granted-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-type {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-descrition {
|
||||
|
||||
.badge-description {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0px
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
font-weight: 400;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {AllBadgesBadge} from '../../types/badges';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
import {markdown} from 'utils/markdown';
|
||||
|
||||
import './all_badges_row.scss';
|
||||
@ -43,29 +43,46 @@ function getGrantedText(badge: AllBadgesBadge): React.ReactNode {
|
||||
|
||||
const AllBadgesRow: React.FC<Props> = ({badge, onClick}: Props) => {
|
||||
return (
|
||||
<div className='AllBadgesRow'>
|
||||
<a
|
||||
className='badge-icon'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
<div>
|
||||
<div className='badge-name'>{badge.name}</div>
|
||||
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||
<div className='badge-type'>
|
||||
<div
|
||||
className='AllBadgesRow'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span className='badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={36}
|
||||
/>
|
||||
</span>
|
||||
<div className='badge-text'>
|
||||
<div className='badge-name'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.name'
|
||||
defaultMessage='Название:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.name}
|
||||
</div>
|
||||
<div className='badge-description'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.description'
|
||||
defaultMessage='Описание:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.description ? markdown(badge.description) : '-'}
|
||||
</div>
|
||||
<div className='badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.type'
|
||||
defaultMessage='Тип: {typeName}'
|
||||
values={{typeName: badge.type_name}}
|
||||
/>
|
||||
{' · '}
|
||||
{getGrantedText(badge)}
|
||||
</div>
|
||||
<div className='granted-by'>{getGrantedText(badge)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
73
webapp/src/components/rhs/all_types.scss
Normal file
@ -0,0 +1,73 @@
|
||||
.AllTypes {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
&__tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--button-bg, #166de0);
|
||||
border-bottom-color: var(--button-bg, #166de0);
|
||||
}
|
||||
}
|
||||
|
||||
&__createButton {
|
||||
background: var(--button-bg, #166de0);
|
||||
color: var(--button-color, #fff);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 7px;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
}
|
||||
}
|
||||
99
webapp/src/components/rhs/all_types.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {Virtuoso} from 'react-virtuoso';
|
||||
|
||||
import {BadgeTypeDefinition} from '../../types/badges';
|
||||
import Client from '../../client/api';
|
||||
import {RHS_STATE_TYPE_BADGES} from '../../constants';
|
||||
import {isCreateTypeModalVisible, getEditTypeModalData} from '../../selectors';
|
||||
import {setRHSView, setRHSType, openEditTypeModal} from '../../actions/actions';
|
||||
|
||||
import AllTypesRow from './all_types_row';
|
||||
|
||||
import './all_types.scss';
|
||||
|
||||
const AllTypes: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
|
||||
|
||||
const createTypeVisible = useSelector(isCreateTypeModalVisible);
|
||||
const editTypeData = useSelector(getEditTypeModalData);
|
||||
const isModalOpen = createTypeVisible || editTypeData !== null;
|
||||
const wasModalOpen = useRef(false);
|
||||
|
||||
const fetchTypes = useCallback(async () => {
|
||||
const client = new Client();
|
||||
const resp = await client.getTypes();
|
||||
setTypes(resp.types);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTypes();
|
||||
}, [fetchTypes]);
|
||||
|
||||
// Refetch types when type modal closes (after save/delete)
|
||||
useEffect(() => {
|
||||
if (wasModalOpen.current && !isModalOpen) {
|
||||
fetchTypes();
|
||||
}
|
||||
wasModalOpen.current = isModalOpen;
|
||||
}, [isModalOpen, fetchTypes]);
|
||||
|
||||
const handleEdit = useCallback((badgeType: BadgeTypeDefinition) => {
|
||||
dispatch(openEditTypeModal(badgeType));
|
||||
}, [dispatch]);
|
||||
|
vladimir.khablak
commented
как будто и не надо в зависимости добавлять как будто и не надо в зависимости добавлять
|
||||
|
||||
const handleDelete = useCallback(async (badgeType: BadgeTypeDefinition) => {
|
||||
const client = new Client();
|
||||
await client.deleteType(String(badgeType.id));
|
||||
setTypes((prev) => prev.filter((t) => t.id !== badgeType.id));
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((badgeType: BadgeTypeDefinition) => {
|
||||
dispatch(setRHSType(badgeType.id, badgeType.name));
|
||||
dispatch(setRHSView(RHS_STATE_TYPE_BADGES));
|
||||
}, [dispatch]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='AllTypes AllTypes--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (types.length === 0) {
|
||||
return (
|
||||
<div className='AllTypes__empty'>
|
||||
<FormattedMessage
|
||||
id='badges.types.empty'
|
||||
defaultMessage='Типов пока нет'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
style={{flex: '1 1 auto'}}
|
||||
data={types}
|
||||
increaseViewportBy={300}
|
||||
overscan={200}
|
||||
itemContent={(_index, t) => (
|
||||
<AllTypesRow
|
||||
key={t.id}
|
||||
badgeType={t}
|
||||
onClick={handleClick}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTypes;
|
||||
93
webapp/src/components/rhs/all_types_row.scss
Normal file
@ -0,0 +1,93 @@
|
||||
.AllTypesRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__default {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--button-bg, #166de0);
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
|
||||
&:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
|
||||
&--edit:hover {
|
||||
color: var(--button-bg, #166de0);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.72);
|
||||
|
||||
&:hover {
|
||||
color: var(--error-text, #d24b4e);
|
||||
background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&--cancel:hover {
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
}
|
||||
|
||||
&__confirmDelete {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__confirmText {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
118
webapp/src/components/rhs/all_types_row.tsx
Normal file
@ -0,0 +1,118 @@
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {BadgeTypeDefinition} from '../../types/badges';
|
||||
import ConfirmDialog from '../confirm_dialog/confirm_dialog';
|
||||
|
||||
import './all_types_row.scss';
|
||||
|
||||
type Props = {
|
||||
badgeType: BadgeTypeDefinition;
|
||||
onEdit: (badgeType: BadgeTypeDefinition) => void;
|
||||
onDelete: (badgeType: BadgeTypeDefinition) => void;
|
||||
onClick: (badgeType: BadgeTypeDefinition) => void;
|
||||
}
|
||||
|
||||
const AllTypesRow: React.FC<Props> = ({badgeType, onEdit, onDelete, onClick}: Props) => {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
return;
|
||||
}
|
||||
onDelete(badgeType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='AllTypesRow'
|
||||
onClick={() => onClick(badgeType)}
|
||||
>
|
||||
<div className='AllTypesRow__info'>
|
||||
<div className='AllTypesRow__name'>
|
||||
{badgeType.name}
|
||||
{badgeType.is_default && (
|
||||
<span className='AllTypesRow__default'>
|
||||
<FormattedMessage
|
||||
id='badges.types.is_default'
|
||||
defaultMessage='По умолчанию'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='AllTypesRow__meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.created_by'
|
||||
defaultMessage='Создал: {username}'
|
||||
values={{username: badgeType.created_by_username || badgeType.created_by}}
|
||||
/>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.types.badge_count'
|
||||
defaultMessage='{count, plural, one {# достижение} few {# достижения} many {# достижений} other {# достижений}}'
|
||||
values={{count: badgeType.badge_count}}
|
||||
/>
|
||||
{badgeType.can_create?.everyone && (
|
||||
<>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.types.everyone_can_create'
|
||||
defaultMessage='Все создают'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{badgeType.can_grant?.everyone && (
|
||||
<>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.types.everyone_can_grant'
|
||||
defaultMessage='Все выдают'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='AllTypesRow__actions'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className='AllTypesRow__btn AllTypesRow__btn--edit'
|
||||
onClick={() => onEdit(badgeType)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.edit_badge'
|
||||
defaultMessage='Редактировать'
|
||||
/>
|
||||
</button>
|
||||
{!badgeType.is_default && (
|
||||
<button
|
||||
className='AllTypesRow__btn AllTypesRow__btn--danger'
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.delete_type'
|
||||
defaultMessage='Удалить'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => onDelete(badgeType)}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.confirm_delete_type'
|
||||
defaultMessage='Удалить тип «{name}»?'
|
||||
values={{name: badgeType.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AllTypesRow;
|
||||
@ -3,26 +3,101 @@
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
.badge-info {
|
||||
display: flex;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
.badge-icon {
|
||||
padding: 10px;
|
||||
}
|
||||
.badge-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.created-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.badge-descrition {
|
||||
p {
|
||||
margin: 0px
|
||||
}
|
||||
}
|
||||
.badge-type {
|
||||
font-size: 10px;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
position: relative;
|
||||
|
||||
.badge-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-right: 100px;
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
font-weight: 400;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding-right: 7px;
|
||||
color: var(--center-channel-text);
|
||||
}
|
||||
|
||||
.badge-description {
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__backHeader {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
&__editButton {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-owners {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,17 +2,19 @@ import React from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {BadgeDetails, BadgeID} from '../../types/badges';
|
||||
import Client from '../../client/api';
|
||||
|
||||
import {RHSState} from '../../types/general';
|
||||
import {RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import {IMAGE_TYPE_EMOJI, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
|
||||
import {markdown} from 'utils/markdown';
|
||||
|
||||
import BackButton from '../../components/back_button/back_button';
|
||||
|
||||
import RHSScrollbars from './rhs_scrollbars';
|
||||
import UserRow from './user_row';
|
||||
|
||||
@ -21,10 +23,12 @@ import './badge_details.scss';
|
||||
type Props = {
|
||||
badgeID: BadgeID | null;
|
||||
currentUserID: string;
|
||||
prevView: RHSState;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => void;
|
||||
setRHSUser: (user: string | null) => void;
|
||||
getCustomEmojiByName: (names: string) => void;
|
||||
openEditBadgeModal: (badge: BadgeDetails) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@ -54,8 +58,8 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.state.badge !== prevState.badge && this.state.badge && !systemEmojis.has(this.state.badge.name)) {
|
||||
this.props.actions.getCustomEmojiByName(this.state.badge.name);
|
||||
if (this.state.badge !== prevState.badge && this.state.badge && this.state.badge.image_type === IMAGE_TYPE_EMOJI && !EmojiIndicesByAlias.has(this.state.badge.image)) {
|
||||
this.props.actions.getCustomEmojiByName(this.state.badge.image);
|
||||
}
|
||||
|
||||
if (this.props.badgeID === prevProps.badgeID) {
|
||||
@ -92,17 +96,14 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.badge_not_found'
|
||||
defaultMessage='Значок не найден.'
|
||||
defaultMessage='Достижение не найдено.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
return (<div className='BadgeDetails BadgeDetails--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@ -110,7 +111,7 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.badge_not_found'
|
||||
defaultMessage='Значок не найден.'
|
||||
defaultMessage='Достижение не найдено.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@ -126,30 +127,52 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
});
|
||||
return (
|
||||
<div className='BadgeDetails'>
|
||||
<div><b>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.badge_details'
|
||||
defaultMessage='Детали значка'
|
||||
/>
|
||||
</b></div>
|
||||
<div className='BadgeDetails__backHeader'>
|
||||
<BackButton
|
||||
targetView={this.props.prevView}
|
||||
onNavigate={this.props.actions.setRHSView}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.back_to_achievements'
|
||||
defaultMessage='Назад к достижениям'
|
||||
/>
|
||||
</BackButton>
|
||||
</div>
|
||||
<div className='badge-info'>
|
||||
<span className='badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
size={48}
|
||||
/>
|
||||
</span>
|
||||
<div className='badge-text'>
|
||||
<div className='badge-name'>{badge.name}</div>
|
||||
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||
<div className='badge-type'>
|
||||
<div className='badge-name'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.name'
|
||||
defaultMessage='Название:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.name}
|
||||
</div>
|
||||
<div className='badge-description'>
|
||||
<span className='badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.description'
|
||||
defaultMessage='Описание:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.description ? markdown(badge.description) : '—'}
|
||||
</div>
|
||||
<div className='badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.type'
|
||||
defaultMessage='Тип: {typeName}'
|
||||
values={{typeName: badge.type_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='created-by'>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.label.created_by'
|
||||
defaultMessage='Создал: {username}'
|
||||
@ -157,14 +180,36 @@ class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{badge.can_edit && (
|
||||
<button
|
||||
className='BadgeDetails__editButton'
|
||||
onClick={() => this.props.actions.openEditBadgeModal(badge)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.edit_badge'
|
||||
defaultMessage='Редактировать'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div><b>
|
||||
<FormattedMessage
|
||||
id='badges.granted_to'
|
||||
defaultMessage='Выдан:'
|
||||
/>
|
||||
</b></div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
{badge.owners.length > 0 ? (
|
||||
<>
|
||||
<div className='section-title'>
|
||||
<FormattedMessage
|
||||
id='badges.granted_to'
|
||||
defaultMessage='Выдан:'
|
||||
/>
|
||||
</div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
</>
|
||||
) : (
|
||||
<div className='empty-owners'>
|
||||
<FormattedMessage
|
||||
id='badges.not_granted_yet'
|
||||
defaultMessage='Ещё никому не выдан'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import React from 'react';
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import {getUser} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
@ -13,73 +13,229 @@ import {GlobalState} from 'mattermost-redux/types/store';
|
||||
|
||||
import {getCustomEmojiByName, getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {getRHSBadge, getRHSUser, getRHSView} from 'selectors';
|
||||
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY} from '../../constants';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {getRHSBadge, getRHSUser, getRHSView, getPrevRHSView, getRHSTypeId, getRHSTypeName} from 'selectors';
|
||||
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY, RHS_STATE_TYPES, RHS_STATE_TYPE_BADGES} from '../../constants';
|
||||
import {RHSState} from 'types/general';
|
||||
import {setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
|
||||
import {BadgeID} from 'types/badges';
|
||||
import {openCreateBadgeModal, openCreateTypeModal, openEditBadgeModal, setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
|
||||
import {BadgeDetails, BadgeID} from 'types/badges';
|
||||
import Client from '../../client/api';
|
||||
|
||||
import TooltipWrapper from '../user_popover/tooltip_wrapper';
|
||||
|
||||
import UserBadges from './user_badges';
|
||||
import BadgeDetailsComponent from './badge_details';
|
||||
import AllBadges from './all_badges';
|
||||
import AllTypes from './all_types';
|
||||
|
||||
import './all_badges.scss';
|
||||
|
||||
const RHS: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const currentView = useSelector(getRHSView);
|
||||
const prevView = useSelector(getPrevRHSView);
|
||||
const currentBadge = useSelector(getRHSBadge);
|
||||
const currentUserID = useSelector(getRHSUser);
|
||||
const filterTypeId = useSelector(getRHSTypeId);
|
||||
const filterTypeName = useSelector(getRHSTypeName);
|
||||
const currentUser = useSelector((state: GlobalState) => getUser(state, (currentUserID as string)));
|
||||
const myUser = useSelector(getCurrentUser);
|
||||
|
||||
switch (currentView) {
|
||||
case RHS_STATE_ALL:
|
||||
const [canEditType, setCanEditType] = useState(false);
|
||||
const [canCreateType, setCanCreateType] = useState(false);
|
||||
const [canCreateBadge, setCanCreateBadge] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new Client();
|
||||
client.getTypes().then((resp) => {
|
||||
setCanEditType(resp.can_edit_type);
|
||||
setCanCreateType(resp.can_create_type);
|
||||
setCanCreateBadge(resp.types.length > 0 || resp.can_create_type);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showTabs = currentView === RHS_STATE_MY || currentView === RHS_STATE_ALL || currentView === RHS_STATE_TYPES;
|
||||
|
||||
const handleCreateBadge = useCallback(() => {
|
||||
dispatch(openCreateBadgeModal());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleCreateType = useCallback(() => {
|
||||
dispatch(openCreateTypeModal());
|
||||
}, [dispatch]);
|
||||
|
||||
const renderTabs = () => {
|
||||
if (!showTabs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AllBadges
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
<div className='AllBadges__header'>
|
||||
<div className='AllBadges__tabs'>
|
||||
<button
|
||||
className={'AllBadges__tab' + (currentView === RHS_STATE_MY ? ' AllBadges__tab--active' : '')}
|
||||
onClick={() => dispatch(setRHSView(RHS_STATE_MY))}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.my_badges'
|
||||
defaultMessage='Мои'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={'AllBadges__tab' + (currentView === RHS_STATE_ALL ? ' AllBadges__tab--active' : '')}
|
||||
onClick={() => dispatch(setRHSView(RHS_STATE_ALL))}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.all_badges'
|
||||
defaultMessage='Все достижения'
|
||||
/>
|
||||
</button>
|
||||
{canEditType && (
|
||||
<button
|
||||
className={'AllBadges__tab' + (currentView === RHS_STATE_TYPES ? ' AllBadges__tab--active' : '')}
|
||||
onClick={() => dispatch(setRHSView(RHS_STATE_TYPES))}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.rhs.types'
|
||||
defaultMessage='Типы'
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case RHS_STATE_DETAIL:
|
||||
};
|
||||
|
||||
const renderFab = () => {
|
||||
if (currentView === RHS_STATE_ALL && canCreateBadge) {
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tooltipContent={
|
||||
<FormattedMessage
|
||||
id='badges.rhs.create_badge'
|
||||
defaultMessage='Создать достижение'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
className='AllBadges__fab'
|
||||
onClick={handleCreateBadge}
|
||||
>
|
||||
{'+'}
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
if (currentView === RHS_STATE_TYPES && canCreateType) {
|
||||
|
vladimir.khablak
commented
выглядит как отдельный компонент или useMemo выглядит как отдельный компонент или useMemo
|
||||
return (
|
||||
<TooltipWrapper
|
||||
tooltipContent={
|
||||
<FormattedMessage
|
||||
id='badges.rhs.create_type'
|
||||
defaultMessage='Создать тип'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
className='AllBadges__fab'
|
||||
onClick={handleCreateType}
|
||||
>
|
||||
{'+'}
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (currentView) {
|
||||
case RHS_STATE_TYPES:
|
||||
return <AllTypes/>;
|
||||
case RHS_STATE_TYPE_BADGES:
|
||||
return (
|
||||
<AllBadges
|
||||
filterTypeId={filterTypeId}
|
||||
filterTypeName={filterTypeName}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_ALL:
|
||||
return (
|
||||
<AllBadges
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
openCreateBadgeModal: () => dispatch(openCreateBadgeModal()),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_DETAIL:
|
||||
return (
|
||||
<BadgeDetailsComponent
|
||||
badgeID={currentBadge}
|
||||
currentUserID={myUser.id}
|
||||
prevView={prevView}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
|
||||
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
|
||||
openEditBadgeModal: (badge: BadgeDetails) => dispatch(openEditBadgeModal(badge)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_OTHER:
|
||||
return (
|
||||
<UserBadges
|
||||
user={currentUser}
|
||||
isCurrentUser={false}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_MY:
|
||||
default:
|
||||
return (
|
||||
<UserBadges
|
||||
user={myUser}
|
||||
isCurrentUser={true}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const needsWrapper = showTabs || currentView === RHS_STATE_TYPE_BADGES;
|
||||
|
||||
if (needsWrapper) {
|
||||
return (
|
||||
<BadgeDetailsComponent
|
||||
badgeID={currentBadge}
|
||||
currentUserID={myUser.id}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
|
||||
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_OTHER:
|
||||
return (
|
||||
<UserBadges
|
||||
user={currentUser}
|
||||
isCurrentUser={false}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case RHS_STATE_MY:
|
||||
default:
|
||||
return (
|
||||
<UserBadges
|
||||
user={myUser}
|
||||
isCurrentUser={true}
|
||||
actions={{
|
||||
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||
}}
|
||||
/>
|
||||
<div className='AllBadges'>
|
||||
{renderTabs()}
|
||||
<div className='AllBadges__content'>
|
||||
{renderContent()}
|
||||
{renderFab()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderContent();
|
||||
};
|
||||
|
||||
export default RHS;
|
||||
|
||||
@ -26,7 +26,7 @@ function renderThumbVertical(props: any) {
|
||||
/>);
|
||||
}
|
||||
|
||||
const RHSScrollbars = ({children}: {children: React.ReactNode[]}) => {
|
||||
const RHSScrollbars = ({children}: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<Scrollbars
|
||||
autoHide={true}
|
||||
|
||||
@ -1,28 +1,110 @@
|
||||
.UserBadgesRow {
|
||||
display: flex;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
margin-bottom: 3px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
gap: 12px;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
.user-badge-icon {
|
||||
padding: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-badge-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-badge-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--center-channel-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.user-badge-granted-by {
|
||||
font-size: 10px;
|
||||
}
|
||||
.user-badge-granted-at {
|
||||
font-size: 10px;
|
||||
}
|
||||
.user-badge-descrition {
|
||||
|
||||
.user-badge-description {
|
||||
font-size: 13px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
|
||||
p {
|
||||
margin: 0px
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.user-badge-type {
|
||||
font-size: 10px;
|
||||
|
||||
.user-badge-label {
|
||||
font-weight: 400;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-badge-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-badge-reason {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.user-badge-set-status {
|
||||
margin-top: 4px;
|
||||
|
||||
a {
|
||||
font-size: 12px;
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-badge-revoke {
|
||||
margin-top: 4px;
|
||||
|
||||
a {
|
||||
font-size: 12px;
|
||||
color: var(--error-text, #d24b4e);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
&--confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 12px;
|
||||
color: var(--error-text, #d24b4e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__yes {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__no {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,27 +1,52 @@
|
||||
import React from 'react';
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import Client4 from 'mattermost-redux/client/client4';
|
||||
|
||||
import {UserBadge} from '../../types/badges';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
import {markdown} from 'utils/markdown';
|
||||
import Client from '../../client/api';
|
||||
import ConfirmDialog from '../confirm_dialog/confirm_dialog';
|
||||
|
||||
import './user_badge_row.scss';
|
||||
|
||||
type Props = {
|
||||
badge: UserBadge;
|
||||
isCurrentUser: boolean;
|
||||
currentUserID: string;
|
||||
onClick: (badge: UserBadge) => void;
|
||||
onRevoke?: (badge: UserBadge) => void;
|
||||
}
|
||||
|
||||
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) => {
|
||||
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser, currentUserID, onRevoke}: Props) => {
|
||||
const intl = useIntl();
|
||||
const time = new Date(badge.time);
|
||||
const [confirmingRevoke, setConfirmingRevoke] = useState(false);
|
||||
|
||||
const canRevoke = badge.granted_by === currentUserID;
|
||||
|
||||
const handleRevoke = async () => {
|
||||
try {
|
||||
const client = new Client();
|
||||
await client.revokeOwnership({
|
||||
badge_id: String(badge.id),
|
||||
user_id: badge.user,
|
||||
time: String(badge.time),
|
||||
});
|
||||
onRevoke?.(badge);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setConfirmingRevoke(false);
|
||||
}
|
||||
};
|
||||
|
||||
let reason = null;
|
||||
if (badge.reason) {
|
||||
reason = (
|
||||
<div className='badge-user-reason'>
|
||||
<div className='user-badge-reason'>
|
||||
<FormattedMessage
|
||||
id='badges.label.reason'
|
||||
defaultMessage='Причина: {reason}'
|
||||
@ -35,7 +60,8 @@ const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) =
|
||||
setStatus = (
|
||||
<div className='user-badge-set-status'>
|
||||
<a
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const c = new Client4();
|
||||
c.updateCustomStatus({emoji: badge.image, text: badge.name});
|
||||
}}
|
||||
@ -48,42 +74,98 @@ const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) =
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className='UserBadgesRow'>
|
||||
<a onClick={() => onClick(badge)}>
|
||||
<span className='user-badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={32}
|
||||
|
||||
let revokeAction = null;
|
||||
if (canRevoke && onRevoke) {
|
||||
revokeAction = (
|
||||
<div className='user-badge-revoke'>
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmingRevoke(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.revoke.btn'
|
||||
defaultMessage='Снять достижение'
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
if (confirmingRevoke) {
|
||||
revokeAction = (
|
||||
<>
|
||||
{revokeAction}
|
||||
<ConfirmDialog
|
||||
onConfirm={handleRevoke}
|
||||
onCancel={() => setConfirmingRevoke(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.revoke.confirm'
|
||||
defaultMessage='Снять достижение?'
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className='UserBadgesRow'
|
||||
onClick={() => onClick(badge)}
|
||||
>
|
||||
<span className='user-badge-icon'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={36}
|
||||
/>
|
||||
</span>
|
||||
<div className='user-badge-text'>
|
||||
<div className='user-badge-name'>{badge.name}</div>
|
||||
<div className='user-badge-description'>{markdown(badge.description)}</div>
|
||||
{reason}
|
||||
<div className='user-badge-type'>
|
||||
<div className='user-badge-name'>
|
||||
<span className='user-badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.name'
|
||||
defaultMessage='Название:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.name}
|
||||
</div>
|
||||
<div className='user-badge-description'>
|
||||
<span className='user-badge-label'>
|
||||
<FormattedMessage
|
||||
id='badges.label.description'
|
||||
defaultMessage='Описание:'
|
||||
/>
|
||||
</span>
|
||||
{' '}
|
||||
{badge.description ? markdown(badge.description) : '—'}
|
||||
</div>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.type'
|
||||
defaultMessage='Тип: {typeName}'
|
||||
values={{typeName: badge.type_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='user-badge-granted-by'>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_by'
|
||||
defaultMessage='Выдал: {username}'
|
||||
values={{username: badge.granted_by_name}}
|
||||
/>
|
||||
</div>
|
||||
<div className='user-badge-granted-at'>
|
||||
<div className='user-badge-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_at'
|
||||
defaultMessage='Выдан: {date}'
|
||||
values={{date: time.toDateString()}}
|
||||
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
|
||||
/>
|
||||
</div>
|
||||
{reason}
|
||||
{setStatus}
|
||||
{revokeAction}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -3,4 +3,20 @@
|
||||
flex-flow: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
|
||||
&--loading {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,8 @@ import React from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {BadgeID, UserBadge} from '../../types/badges';
|
||||
import Client from '../../client/api';
|
||||
@ -18,6 +19,7 @@ import './user_badges.scss';
|
||||
|
||||
type Props = {
|
||||
isCurrentUser: boolean;
|
||||
currentUserID: string;
|
||||
user: UserProfile | null;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => void;
|
||||
@ -58,7 +60,7 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
names.push(badge.image);
|
||||
}
|
||||
});
|
||||
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||
const toLoad = names.filter((v) => !EmojiIndicesByAlias.has(v));
|
||||
this.props.actions.getCustomEmojisByName(toLoad);
|
||||
}
|
||||
if (this.props.user?.id === prevProps.user?.id) {
|
||||
@ -84,6 +86,17 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
}
|
||||
|
||||
onRevoke = () => {
|
||||
if (!this.props.user) {
|
||||
return;
|
||||
}
|
||||
const c = new Client();
|
||||
|
vladimir.khablak
commented
пупупуууу пупупуууу
|
||||
this.setState({loading: true});
|
||||
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||
this.setState({badges, loading: false});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.user) {
|
||||
return (<div>
|
||||
@ -95,11 +108,8 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
if (this.state.loading) {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
return (<div className='UserBadges UserBadges--loading'>
|
||||
<div className='spinner'/>
|
||||
</div>);
|
||||
}
|
||||
|
||||
@ -107,7 +117,7 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
return (<div>
|
||||
<FormattedMessage
|
||||
id='badges.no_badges_yet'
|
||||
defaultMessage='Значков пока нет.'
|
||||
defaultMessage='Достижений пока нет.'
|
||||
/>
|
||||
</div>);
|
||||
}
|
||||
@ -116,9 +126,11 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
return (
|
||||
<UserBadgeRow
|
||||
isCurrentUser={this.props.isCurrentUser}
|
||||
currentUserID={this.props.currentUserID}
|
||||
key={badge.time}
|
||||
badge={badge}
|
||||
onClick={this.onBadgeClick}
|
||||
onRevoke={this.onRevoke}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -126,18 +138,18 @@ class UserBadges extends React.PureComponent<Props, State> {
|
||||
const title = this.props.isCurrentUser ? (
|
||||
<FormattedMessage
|
||||
id='badges.rhs.my_badges'
|
||||
defaultMessage='Мои значки'
|
||||
defaultMessage='Мои достижения'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='badges.rhs.user_badges'
|
||||
defaultMessage='Значки @{username}'
|
||||
defaultMessage='Достижения @{username}'
|
||||
values={{username: this.props.user.username}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className='UserBadges'>
|
||||
<div><b>{title}</b></div>
|
||||
<div className='UserBadges__title'>{title}</div>
|
||||
<RHSScrollbars>{content}</RHSScrollbars>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,13 +1,32 @@
|
||||
.UserRow {
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
margin-bottom: 3px;
|
||||
.badge-user-username {
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
.badge-user-granted-at {
|
||||
font-size: 10px;
|
||||
|
||||
.badge-user-username {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
a {
|
||||
color: var(--button-bg, #166de0);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badge-user-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import {Ownership} from '../../types/badges';
|
||||
|
||||
import './user_row.scss';
|
||||
|
||||
type Props = {
|
||||
ownership: Ownership;
|
||||
onClick: (user: string) => void;
|
||||
@ -31,20 +32,24 @@ const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
|
||||
|
||||
const time = new Date(ownership.time);
|
||||
return (
|
||||
<div className='UserRow'>
|
||||
<div className='badge-user-username'><a onClick={() => onClick(ownership.user)}>{`@${user.username}`}</a></div>
|
||||
<div className='badge-user-granted-by'>
|
||||
<div
|
||||
className='UserRow'
|
||||
onClick={() => onClick(ownership.user)}
|
||||
>
|
||||
<div className='badge-user-username'>
|
||||
<a>{`@${user.username}`}</a>
|
||||
</div>
|
||||
<div className='badge-user-meta'>
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_by'
|
||||
defaultMessage='Выдал: {username}'
|
||||
values={{username: grantedByName}}
|
||||
/>
|
||||
</div>
|
||||
<div className='badge-user-granted-at'>
|
||||
{' · '}
|
||||
<FormattedMessage
|
||||
id='badges.label.granted_at'
|
||||
defaultMessage='Выдан: {date}'
|
||||
values={{date: time.toDateString()}}
|
||||
values={{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
206
webapp/src/components/subscription_modal/index.tsx
Normal file
@ -0,0 +1,206 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/common';
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
|
||||
import {closeSubscriptionModal} from 'actions/actions';
|
||||
import {getSubscriptionModalData} from 'selectors';
|
||||
import {BadgeTypeDefinition} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import {getServerErrorId} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
|
||||
const SubscriptionModal: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const modalData = useSelector(getSubscriptionModalData);
|
||||
const channelId = useSelector((state: GlobalState) => getCurrentChannelId(state));
|
||||
const isOpen = modalData !== null;
|
||||
const isDeleteMode = modalData?.mode === 'delete';
|
||||
|
||||
const [selectedTypeId, setSelectedTypeId] = useState('');
|
||||
const [types, setTypes] = useState<BadgeTypeDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [typeDropdownOpen, setTypeDropdownOpen] = useState(false);
|
||||
const typeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Закрытие дропдауна при клике снаружи
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (typeDropdownRef.current && !typeDropdownRef.current.contains(e.target as Node)) {
|
||||
setTypeDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const fetchTypes = async () => {
|
||||
const client = new Client();
|
||||
const subs = await client.getChannelSubscriptions(channelId);
|
||||
if (isDeleteMode) {
|
||||
setTypes(subs);
|
||||
} else {
|
||||
const resp = await client.getTypes();
|
||||
const subscribedIds = new Set(subs.map((s) => String(s.id)));
|
||||
setTypes(resp.types.filter((t) => !subscribedIds.has(String(t.id))));
|
||||
}
|
||||
};
|
||||
fetchTypes();
|
||||
setSelectedTypeId('');
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
setTypeDropdownOpen(false);
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
dispatch(closeSubscriptionModal());
|
||||
setClosing(false);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
}, [doClose]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedTypeId) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
const req = {type_id: selectedTypeId, channel_id: channelId};
|
||||
if (isDeleteMode) {
|
||||
await client.deleteSubscription(req);
|
||||
} else {
|
||||
await client.createSubscription(req);
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTypeId, channelId, isDeleteMode, handleClose, intl]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = isDeleteMode
|
||||
? intl.formatMessage({id: 'badges.subscription.title_delete', defaultMessage: 'Удалить подписку'})
|
||||
: intl.formatMessage({id: 'badges.subscription.title_create', defaultMessage: 'Добавить подписку'});
|
||||
const submitLabel = isDeleteMode
|
||||
? intl.formatMessage({id: 'badges.subscription.btn_delete', defaultMessage: 'Удалить'})
|
||||
: intl.formatMessage({id: 'badges.subscription.btn_create', defaultMessage: 'Добавить'});
|
||||
|
||||
const selectedType = types.find((t) => String(t.id) === selectedTypeId);
|
||||
|
||||
return (
|
||||
<div className={'BadgeModal BadgeModal--compact' + (closing ? ' BadgeModal--closing' : '')}>
|
||||
<div
|
||||
className='BadgeModal__backdrop'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className='BadgeModal__dialog'>
|
||||
<div className='BadgeModal__header'>
|
||||
<h4>{title}</h4>
|
||||
<button
|
||||
className='close-btn'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</button>
|
||||
</div>
|
||||
<div className='BadgeModal__body'>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.subscription.field_type'
|
||||
defaultMessage='Тип достижений'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<div
|
||||
className='type-select'
|
||||
ref={typeDropdownRef}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='type-select__trigger'
|
||||
onClick={() => setTypeDropdownOpen(!typeDropdownOpen)}
|
||||
>
|
||||
<span className='type-select__value'>
|
||||
{selectedType
|
||||
? selectedType.name
|
||||
: intl.formatMessage({id: 'badges.subscription.field_type_placeholder', defaultMessage: 'Выберите тип достижений'})
|
||||
}
|
||||
</span>
|
||||
<span className='type-select__arrow'>{'▾'}</span>
|
||||
</button>
|
||||
{typeDropdownOpen && (
|
||||
<div className='type-select__dropdown'>
|
||||
{types.length === 0 && (
|
||||
<div className='type-select__option'>
|
||||
<FormattedMessage
|
||||
id='badges.subscription.no_types'
|
||||
defaultMessage='Нет доступных типов'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{types.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={'type-select__option' + (String(t.id) === selectedTypeId ? ' type-select__option--selected' : '')}
|
||||
onClick={() => {
|
||||
setSelectedTypeId(String(t.id));
|
||||
setTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className='type-select__option-name'>{t.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
</div>
|
||||
<div className='BadgeModal__footer'>
|
||||
<button
|
||||
className='btn btn--cancel'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className={isDeleteMode ? 'btn btn--danger' : 'btn btn--primary'}
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !selectedTypeId}
|
||||
>
|
||||
{loading
|
||||
? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'})
|
||||
: submitLabel
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionModal;
|
||||
286
webapp/src/components/type_modal/index.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {TypeFormData} from 'types/badges';
|
||||
import {isCreateTypeModalVisible, getEditTypeModalData} from 'selectors';
|
||||
import {closeCreateTypeModal, closeEditTypeModal} from 'actions/actions';
|
||||
import Client from 'client/api';
|
||||
import {getServerErrorId} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
import UserMultiSelect from 'components/user_multi_select';
|
||||
import ConfirmDialog from 'components/confirm_dialog/confirm_dialog';
|
||||
|
||||
const emptyTypeForm: TypeFormData = {
|
||||
name: '',
|
||||
everyoneCanCreate: false,
|
||||
everyoneCanGrant: false,
|
||||
allowlistCanCreate: '',
|
||||
allowlistCanGrant: '',
|
||||
};
|
||||
|
||||
const TypeModal: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const createVisible = useSelector(isCreateTypeModalVisible);
|
||||
const editData = useSelector(getEditTypeModalData);
|
||||
const isOpen = createVisible || editData !== null;
|
||||
const isEditMode = editData !== null;
|
||||
|
||||
const [form, setForm] = useState<TypeFormData>(emptyTypeForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const updateForm = useCallback((updates: Partial<TypeFormData>) => {
|
||||
setForm((prev) => ({...prev, ...updates}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditMode && editData) {
|
||||
setForm({
|
||||
name: editData.name,
|
||||
everyoneCanCreate: editData.can_create?.everyone || false,
|
||||
everyoneCanGrant: editData.can_grant?.everyone || false,
|
||||
allowlistCanCreate: editData.allowlist_can_create || '',
|
||||
allowlistCanGrant: editData.allowlist_can_grant || '',
|
||||
});
|
||||
} else {
|
||||
setForm(emptyTypeForm);
|
||||
}
|
||||
setError(null);
|
||||
setConfirmDelete(false);
|
||||
setLoading(false);
|
||||
}, [isOpen, isEditMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const doClose = useCallback(() => {
|
||||
if (createVisible) {
|
||||
dispatch(closeCreateTypeModal());
|
||||
}
|
||||
if (editData) {
|
||||
dispatch(closeEditTypeModal());
|
||||
}
|
||||
setClosing(false);
|
||||
}, [dispatch, createVisible, editData]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(doClose, 150);
|
||||
}, [doClose]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
опять клиент опять клиент
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
everyone_can_create: form.everyoneCanCreate,
|
||||
everyone_can_grant: form.everyoneCanGrant,
|
||||
allowlist_can_create: form.allowlistCanCreate.trim(),
|
||||
allowlist_can_grant: form.allowlistCanGrant.trim(),
|
||||
};
|
||||
if (isEditMode && editData) {
|
||||
await client.updateType({id: String(editData.id), ...payload});
|
||||
} else {
|
||||
await client.createType(payload);
|
||||
}
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isEditMode, editData, form, handleClose, intl]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!editData) {
|
||||
return;
|
||||
}
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new Client();
|
||||
|
vladimir.khablak
commented
опять он опять он
|
||||
await client.deleteType(String(editData.id));
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setError(intl.formatMessage({id: 'badges.error.' + (getServerErrorId(err) || 'unknown'), defaultMessage: 'Произошла ошибка'}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [editData, confirmDelete, handleClose, intl]);
|
||||
|
||||
if (!isOpen && !closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = isEditMode
|
||||
? intl.formatMessage({id: 'badges.modal.edit_type_title', defaultMessage: 'Редактировать тип'})
|
||||
: intl.formatMessage({id: 'badges.modal.create_type_title', defaultMessage: 'Создать тип'});
|
||||
const submitLabel = isEditMode
|
||||
? intl.formatMessage({id: 'badges.modal.btn_save', defaultMessage: 'Сохранить'})
|
||||
: intl.formatMessage({id: 'badges.modal.btn_create', defaultMessage: 'Создать'});
|
||||
|
||||
return (
|
||||
<div className={'BadgeModal' + (closing ? ' BadgeModal--closing' : '')}>
|
||||
<div
|
||||
className='BadgeModal__backdrop'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className='BadgeModal__dialog'>
|
||||
<div className='BadgeModal__header'>
|
||||
<h4>{title}</h4>
|
||||
<button
|
||||
className='close-btn'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<CloseIcon/>
|
||||
</button>
|
||||
</div>
|
||||
<div className='BadgeModal__body'>
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.field_name'
|
||||
defaultMessage='Название'
|
||||
/>
|
||||
<span className='required'>{'*'}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={form.name}
|
||||
onChange={(e) => updateForm({name: e.target.value})}
|
||||
maxLength={20}
|
||||
placeholder={intl.formatMessage({id: 'badges.modal.new_type_name_placeholder', defaultMessage: 'Название типа (макс. 20 символов)'})}
|
||||
/>
|
||||
</div>
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='typeEveryoneCanCreate'
|
||||
checked={form.everyoneCanCreate}
|
||||
onChange={(e) => updateForm({everyoneCanCreate: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='typeEveryoneCanCreate'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.new_type_everyone_create'
|
||||
defaultMessage='Все могут создавать достижения'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!form.everyoneCanCreate && (
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.allowlist_create'
|
||||
defaultMessage='Список допущенных к созданию'
|
||||
/>
|
||||
</label>
|
||||
<UserMultiSelect
|
||||
value={form.allowlistCanCreate}
|
||||
onChange={(v) => updateForm({allowlistCanCreate: v})}
|
||||
/>
|
||||
<span className='form-group__help'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.allowlist_create_help'
|
||||
defaultMessage='Пользователи, которые могут создавать достижения этого типа.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='checkbox-group'>
|
||||
<input
|
||||
type='checkbox'
|
||||
id='typeEveryoneCanGrant'
|
||||
checked={form.everyoneCanGrant}
|
||||
onChange={(e) => updateForm({everyoneCanGrant: e.target.checked})}
|
||||
/>
|
||||
<label htmlFor='typeEveryoneCanGrant'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.new_type_everyone_grant'
|
||||
defaultMessage='Все могут выдавать достижения'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{!form.everyoneCanGrant && (
|
||||
<div className='form-group'>
|
||||
<label>
|
||||
<FormattedMessage
|
||||
id='badges.modal.allowlist_grant'
|
||||
defaultMessage='Список допущенных к выдаче'
|
||||
/>
|
||||
</label>
|
||||
<UserMultiSelect
|
||||
value={form.allowlistCanGrant}
|
||||
onChange={(v) => updateForm({allowlistCanGrant: v})}
|
||||
/>
|
||||
<span className='form-group__help'>
|
||||
<FormattedMessage
|
||||
id='badges.modal.allowlist_grant_help'
|
||||
defaultMessage='Пользователи, которые могут выдавать достижения этого типа.'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className='error-message'>{error}</div>}
|
||||
{isEditMode && !editData?.is_default && (
|
||||
<div className='delete-section'>
|
||||
<button
|
||||
className='btn btn--danger'
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_delete_type'
|
||||
defaultMessage='Удалить тип'
|
||||
/>
|
||||
</button>
|
||||
{confirmDelete && (
|
||||
<ConfirmDialog
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.types.confirm_delete'
|
||||
defaultMessage='Удалить тип «{name}» и все его достижения?'
|
||||
values={{name: editData?.name}}
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='BadgeModal__footer'>
|
||||
<button
|
||||
className='btn btn--cancel'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='badges.modal.btn_cancel'
|
||||
defaultMessage='Отмена'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className='btn btn--primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !form.name.trim()}
|
||||
>
|
||||
{loading ? intl.formatMessage({id: 'badges.modal.btn_creating', defaultMessage: 'Сохранение...'}) : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypeModal;
|
||||
244
webapp/src/components/user_multi_select/index.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
|
||||
import {debounce, getUserDisplayName} from 'utils/helpers';
|
||||
import CloseIcon from 'components/icons/close_icon';
|
||||
import SearchIcon from 'components/icons/search_icon';
|
||||
|
||||
import './user_multi_select.scss';
|
||||
|
||||
type SelectedUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
fullName: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const UserMultiSelect: React.FC<Props> = ({value, onChange, placeholder, disabled}) => {
|
||||
const intl = useIntl();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [results, setResults] = useState<UserProfile[]>([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<SelectedUser[]>([]);
|
||||
|
||||
const loadedValueRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (loadedValueRef.current === value) {
|
||||
|
vladimir.khablak
commented
как будто можно инвертировать условие if (loadedValueRef.current !== value && value) {...} или типо того как будто можно инвертировать условие if (loadedValueRef.current !== value && value) {...} или типо того
|
||||
// Already synced — nothing to do
|
||||
} else if (value) {
|
||||
const usernames = value.split(',').map((u) => u.trim()).filter(Boolean);
|
||||
if (usernames.length === 0) {
|
||||
setSelectedUsers([]);
|
||||
loadedValueRef.current = value;
|
||||
} else {
|
||||
setProfilesLoading(true);
|
||||
Promise.all(usernames.map(async (username) => {
|
||||
try {
|
||||
const user = await Client4.getUserByUsername(username);
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
fullName: getUserDisplayName(user),
|
||||
avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update),
|
||||
};
|
||||
} catch {
|
||||
return {id: '', username, fullName: '', avatarUrl: ''};
|
||||
}
|
||||
})).then((users) => {
|
||||
if (!cancelled) {
|
||||
setSelectedUsers(users);
|
||||
loadedValueRef.current = value;
|
||||
setProfilesLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setSelectedUsers([]);
|
||||
setProfilesLoading(false);
|
||||
loadedValueRef.current = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const performSearch = async (term: string, excluded: Set<string>) => {
|
||||
if (!term) {
|
||||
setResults([]);
|
||||
setDropdownOpen(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await Client4.autocompleteUsers(term, '', '', {limit: 20});
|
||||
setResults(data.users.filter((u) => !excluded.has(u.username) && !(u as UserProfile & {remote_id?: string}).remote_id));
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doSearch = useMemo(() => debounce(performSearch, 400), []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const term = e.target.value;
|
||||
setSearchTerm(term);
|
||||
if (term) {
|
||||
setDropdownOpen(true);
|
||||
}
|
||||
doSearch(term, new Set(selectedUsers.map((u) => u.username)));
|
||||
};
|
||||
|
||||
const handleSelect = (user: UserProfile) => {
|
||||
const next = [...selectedUsers, {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
fullName: getUserDisplayName(user),
|
||||
avatarUrl: Client4.getProfilePictureUrl(user.id, user.last_picture_update),
|
||||
}];
|
||||
setSelectedUsers(next);
|
||||
const newValue = next.map((u) => u.username).join(', ');
|
||||
loadedValueRef.current = newValue;
|
||||
onChange(newValue);
|
||||
setSearchTerm('');
|
||||
setResults([]);
|
||||
setDropdownOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleRemove = (username: string) => {
|
||||
const next = selectedUsers.filter((u) => u.username !== username);
|
||||
setSelectedUsers(next);
|
||||
const newValue = next.map((u) => u.username).join(', ');
|
||||
loadedValueRef.current = newValue;
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const placeholderText = placeholder || intl.formatMessage({
|
||||
id: 'badges.admin.placeholder',
|
||||
defaultMessage: 'Начните вводить имя...',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className='user-multi-select'
|
||||
ref={containerRef}
|
||||
>
|
||||
<div
|
||||
className='user-multi-select__container'
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{(loading || profilesLoading) ? (
|
||||
<div className='user-multi-select__spinner'/>
|
||||
) : (
|
||||
<SearchIcon/>
|
||||
)}
|
||||
{profilesLoading ? null : selectedUsers.map((user) => (
|
||||
|
vladimir.khablak
commented
на твое усмотрение {!profilesLoading && selectedUsers.map...} на твое усмотрение {!profilesLoading && selectedUsers.map...}
|
||||
<span
|
||||
key={user.username}
|
||||
className='user-multi-select__chip'
|
||||
>
|
||||
{user.avatarUrl && (
|
||||
<img
|
||||
className='user-multi-select__chip-avatar'
|
||||
src={user.avatarUrl}
|
||||
alt={user.username}
|
||||
/>
|
||||
)}
|
||||
<span className='user-multi-select__chip-name'>
|
||||
{user.fullName || user.username}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type='button'
|
||||
className='user-multi-select__chip-remove'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemove(user.username);
|
||||
}}
|
||||
>
|
||||
<CloseIcon size={12}/>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className='user-multi-select__input'
|
||||
type='text'
|
||||
value={searchTerm}
|
||||
disabled={disabled}
|
||||
onChange={handleInputChange}
|
||||
placeholder={selectedUsers.length === 0 ? placeholderText : ''}
|
||||
/>
|
||||
</div>
|
||||
{dropdownOpen && (
|
||||
<div className='user-multi-select__dropdown'>
|
||||
{results.length === 0 && searchTerm && (
|
||||
<div className={`user-multi-select__no-results${loading ? ' user-multi-select__no-results--loading' : ''}`}>
|
||||
{intl.formatMessage({
|
||||
id: 'badges.admin.no_results',
|
||||
defaultMessage: 'Пользователь не найден',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{results.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className='user-multi-select__option'
|
||||
onClick={() => handleSelect(user)}
|
||||
>
|
||||
<img
|
||||
className='user-multi-select__avatar'
|
||||
src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)}
|
||||
alt={user.username}
|
||||
/>
|
||||
<span className='user-multi-select__option-name'>
|
||||
{user.username}
|
||||
</span>
|
||||
{(user.first_name || user.last_name) && (
|
||||
<span className='user-multi-select__option-fullname'>
|
||||
{'— '}{`${user.first_name} ${user.last_name}`.trim()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserMultiSelect;
|
||||
156
webapp/src/components/user_multi_select/user_multi_select.scss
Normal file
@ -0,0 +1,156 @@
|
||||
.user-multi-select {
|
||||
position: relative;
|
||||
|
||||
&__container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
min-height: 34px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
cursor: text;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--button-bg, #166de0);
|
||||
box-shadow: 0 0 0 1px var(--button-bg, #166de0);
|
||||
}
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-top-color: var(--button-bg, #166de0);
|
||||
border-radius: 50%;
|
||||
animation: user-multi-select-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
&__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--button-bg-rgb, 22, 109, 224), 0.1);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__chip-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__chip-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1 1 60px;
|
||||
min-width: 60px;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
}
|
||||
}
|
||||
|
||||
&__dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background: var(--center-channel-bg, #fff);
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.16);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
&__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--center-channel-color, #3d3c40);
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__option-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__option-fullname {
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
}
|
||||
|
||||
&__no-results {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.56);
|
||||
font-style: italic;
|
||||
|
||||
&--loading {
|
||||
color: rgba(var(--center-channel-color-rgb, 61, 60, 64), 0.32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes user-multi-select-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
display: flex;
|
||||
align-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#showMoreButton {
|
||||
@ -23,6 +24,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.badge-stacked {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.badge-stack-count {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
background: var(--button-bg, #166de0);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 1px 3px;
|
||||
border-radius: 6px;
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#grantBadgeButton {
|
||||
margin-top: 4px;
|
||||
padding-left: 0;
|
||||
|
||||
@ -1,209 +1,175 @@
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
import React from 'react';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {FormattedMessage, injectIntl, IntlShape} from 'react-intl';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import {BadgeID, UserBadge} from 'types/badges';
|
||||
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {EmojiIndicesByAlias} from 'utils/emoji';
|
||||
|
||||
import {UserBadge} from 'types/badges';
|
||||
import Client from 'client/api';
|
||||
import BadgeImage from '../utils/badge_image';
|
||||
import TooltipWrapper from '../utils/tooltip_wrapper';
|
||||
import {RHSState} from 'types/general';
|
||||
import BadgeImage from '../badge_image/badge_image';
|
||||
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
|
||||
import {getShowRHS} from 'selectors';
|
||||
import {groupBadges} from 'components/utils/badge_list_utils';
|
||||
|
||||
import BadgeTooltip from './badge_tooltip';
|
||||
import TooltipWrapper from './tooltip_wrapper';
|
||||
import './badge_list.scss';
|
||||
|
||||
type Props = {
|
||||
intl: IntlShape;
|
||||
debug: GlobalState;
|
||||
user: UserProfile;
|
||||
currentUserID: string;
|
||||
openRHS: (() => void) | null;
|
||||
hide: () => void;
|
||||
status?: string;
|
||||
actions: {
|
||||
setRHSView: (view: RHSState) => Promise<void>;
|
||||
setRHSBadge: (id: BadgeID | null) => Promise<void>;
|
||||
setRHSUser: (id: string | null) => Promise<void>;
|
||||
openGrant: (user?: string, badge?: string) => Promise<void>;
|
||||
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
type State = {
|
||||
badges?: UserBadge[];
|
||||
loaded?: boolean;
|
||||
}
|
||||
|
||||
const MAX_BADGES = 7;
|
||||
const MAX_BADGES = 6;
|
||||
const BADGE_SIZE = 24;
|
||||
|
||||
class BadgeList extends React.PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const BadgeList: React.FC<Props> = ({user, hide}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const currentUserID = useSelector(getCurrentUserId);
|
||||
const openRHS = useSelector(getShowRHS);
|
||||
const [badges, setBadges] = useState<UserBadge[]>();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
useEffect(() => {
|
||||
const c = new Client();
|
||||
|
vladimir.khablak
commented
client client
|
||||
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||
this.setState({badges, loaded: true});
|
||||
c.getUserBadges(user.id).then((result) => {
|
||||
setBadges(result);
|
||||
setLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [user.id]);
|
||||
|
||||
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||
if (this.state.badges !== prevState.badges) {
|
||||
const nBadges = this.state.badges?.length || 0;
|
||||
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
|
||||
const names: string[] = [];
|
||||
for (let i = 0; i < toShow; i++) {
|
||||
const badge = this.state.badges![i];
|
||||
if (badge.image_type === IMAGE_TYPE_EMOJI) {
|
||||
names.push(badge.image);
|
||||
}
|
||||
}
|
||||
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||
this.props.actions.getCustomEmojisByName(toLoad);
|
||||
}
|
||||
}
|
||||
const groups = useMemo(
|
||||
() => (badges ? groupBadges(badges) : []),
|
||||
[badges],
|
||||
);
|
||||
|
||||
onMoreClick = () => {
|
||||
if (!this.props.openRHS) {
|
||||
useEffect(() => {
|
||||
if (!badges) {
|
||||
return;
|
||||
}
|
||||
const toShow = groups.slice(0, MAX_BADGES);
|
||||
const names = toShow.
|
||||
|
vladimir.khablak
commented
как будто filter.map.filter можно заменить на reduce как будто filter.map.filter можно заменить на reduce
|
||||
filter(({badge}) => badge.image_type === IMAGE_TYPE_EMOJI).
|
||||
map(({badge}) => badge.image).
|
||||
filter((v) => !EmojiIndicesByAlias.has(v));
|
||||
if (names.length > 0) {
|
||||
dispatch(getCustomEmojisByName(names));
|
||||
}
|
||||
}, [badges, groups, dispatch]);
|
||||
|
||||
if (this.props.currentUserID === this.props.user.id) {
|
||||
this.props.actions.setRHSView(RHS_STATE_MY);
|
||||
this.props.openRHS();
|
||||
const handleMoreClick = useCallback(() => {
|
||||
if (!openRHS) {
|
||||
return;
|
||||
}
|
||||
if (currentUserID === user.id) {
|
||||
dispatch(setRHSView(RHS_STATE_MY));
|
||||
} else {
|
||||
dispatch(setRHSUser(user.id));
|
||||
dispatch(setRHSView(RHS_STATE_OTHER));
|
||||
}
|
||||
openRHS();
|
||||
hide();
|
||||
}, [openRHS, currentUserID, user.id, dispatch, hide]);
|
||||
|
||||
this.props.actions.setRHSUser(this.props.user.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_OTHER);
|
||||
this.props.openRHS();
|
||||
this.props.hide();
|
||||
}
|
||||
|
||||
onBadgeClick = (badge: UserBadge) => {
|
||||
if (!this.props.openRHS) {
|
||||
const handleBadgeClick = useCallback((badge: UserBadge) => {
|
||||
if (!openRHS) {
|
||||
return;
|
||||
}
|
||||
dispatch(setRHSBadge(badge.id));
|
||||
dispatch(setRHSView(RHS_STATE_DETAIL));
|
||||
openRHS();
|
||||
hide();
|
||||
}, [openRHS, dispatch, hide]);
|
||||
|
||||
this.props.actions.setRHSBadge(badge.id);
|
||||
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||
this.props.openRHS();
|
||||
this.props.hide();
|
||||
const handleGrantClick = useCallback(() => {
|
||||
dispatch(openGrant(user.username));
|
||||
hide();
|
||||
}, [dispatch, user.username, hide]);
|
||||
|
||||
if ((user as UserProfile & {remote_id?: string}).remote_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
onGrantClick = () => {
|
||||
this.props.actions.openGrant(this.props.user.username);
|
||||
this.props.hide();
|
||||
}
|
||||
const visibleGroups = groups.slice(0, MAX_BADGES);
|
||||
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
|
||||
|
||||
render() {
|
||||
const {intl} = this.props;
|
||||
const nBadges = this.state.badges?.length || 0;
|
||||
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
|
||||
|
||||
const content: React.ReactNode[] = [];
|
||||
for (let i = 0; i < toShow; i++) {
|
||||
const badge = this.state.badges![i];
|
||||
const time = new Date(badge.time);
|
||||
let reason: string | null = null;
|
||||
if (badge.reason) {
|
||||
reason = intl.formatMessage(
|
||||
{id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'},
|
||||
{reason: badge.reason},
|
||||
);
|
||||
}
|
||||
const grantedBy = intl.formatMessage(
|
||||
{id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'},
|
||||
{username: badge.granted_by_name},
|
||||
);
|
||||
const grantedAt = intl.formatMessage(
|
||||
{id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'},
|
||||
{date: time.toDateString()},
|
||||
);
|
||||
const tooltipLines = [
|
||||
badge.name,
|
||||
badge.description,
|
||||
reason,
|
||||
grantedBy,
|
||||
grantedAt,
|
||||
].filter(Boolean).join('\n');
|
||||
const badgeComponent = (
|
||||
<TooltipWrapper tooltipContent={tooltipLines}>
|
||||
<a onClick={() => this.onBadgeClick(badge)}>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={BADGE_SIZE}
|
||||
/>
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
content.push(badgeComponent);
|
||||
}
|
||||
let andMore: React.ReactNode = null;
|
||||
if (nBadges > MAX_BADGES) {
|
||||
const andMoreText = intl.formatMessage(
|
||||
{id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'},
|
||||
{count: nBadges - MAX_BADGES},
|
||||
);
|
||||
andMore = (
|
||||
<TooltipWrapper tooltipContent={andMoreText}>
|
||||
<button
|
||||
id='showMoreButton'
|
||||
onClick={this.onMoreClick}
|
||||
return (
|
||||
<div id='badgePlugin'>
|
||||
<div><b>
|
||||
|
vladimir.khablak
commented
немного поплыли стили немного поплыли стили
|
||||
<FormattedMessage
|
||||
id='badges.popover.title'
|
||||
defaultMessage='Достижения'
|
||||
/>
|
||||
</b></div>
|
||||
<div id='contentContainer'>
|
||||
{visibleGroups.map(({badge, count}) => (
|
||||
<TooltipWrapper
|
||||
key={badge.id}
|
||||
tooltipContent={
|
||||
<BadgeTooltip
|
||||
badge={badge}
|
||||
count={count}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className={'fa fa-angle-right'}/>
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
|
||||
let loading: React.ReactNode = null;
|
||||
if (!this.state.loaded) {
|
||||
loading = (
|
||||
|
||||
// Reserve enough height one row of badges and the "and more" button
|
||||
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
|
||||
<FormattedMessage
|
||||
id='badges.loading'
|
||||
defaultMessage='Загрузка...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div id='badgePlugin'>
|
||||
<div><b>
|
||||
<FormattedMessage
|
||||
id='badges.popover.title'
|
||||
defaultMessage='Значки'
|
||||
/>
|
||||
</b></div>
|
||||
<div id='contentContainer' >
|
||||
{content}
|
||||
{andMore}
|
||||
</div>
|
||||
{loading}
|
||||
<button
|
||||
id='grantBadgeButton'
|
||||
onClick={this.onGrantClick}
|
||||
>
|
||||
<span className={'fa fa-plus-circle'}/>
|
||||
<FormattedMessage
|
||||
id='badges.grant_badge'
|
||||
defaultMessage='Выдать значок'
|
||||
/>
|
||||
</button>
|
||||
<hr className='divider divider--expanded'/>
|
||||
<a onClick={() => handleBadgeClick(badge)}>
|
||||
<span className='badge-stacked'>
|
||||
<BadgeImage
|
||||
badge={badge}
|
||||
size={BADGE_SIZE}
|
||||
/>
|
||||
{count > 1 && (
|
||||
<span className='badge-stack-count'>
|
||||
{'×'}{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</a>
|
||||
</TooltipWrapper>
|
||||
))}
|
||||
{groups.length > MAX_BADGES && (
|
||||
<TooltipWrapper
|
||||
tooltipContent={intl.formatMessage(
|
||||
{id: 'badges.and_more', defaultMessage: 'и ещё {count}. Нажмите, чтобы увидеть все.'},
|
||||
{count: groups.length - MAX_BADGES},
|
||||
)}
|
||||
>
|
||||
<button
|
||||
id='showMoreButton'
|
||||
onClick={handleMoreClick}
|
||||
>
|
||||
<span className={'fa fa-angle-right'}/>
|
||||
</button>
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
{!loaded && (
|
||||
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
|
||||
<div className='spinner'/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
id='grantBadgeButton'
|
||||
onClick={handleGrantClick}
|
||||
>
|
||||
<span className={'fa fa-plus-circle'}/>
|
||||
<FormattedMessage
|
||||
id='badges.grant_badge'
|
||||
defaultMessage='Выдать достижение'
|
||||
/>
|
||||
</button>
|
||||
<hr className='divider divider--expanded'/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectIntl(BadgeList);
|
||||
export default BadgeList;
|
||||
|
||||
62
webapp/src/components/user_popover/badge_tooltip.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {UserBadge} from 'types/badges';
|
||||
|
||||
import {truncateText} from 'components/utils/badge_list_utils';
|
||||
|
||||
type Props = {
|
||||
badge: UserBadge;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const BadgeTooltip: React.FC<Props> = ({badge, count}) => {
|
||||
const intl = useIntl();
|
||||
const desc = badge.description ? truncateText(badge.description) : '—';
|
||||
|
||||
const nameRow = intl.formatMessage(
|
||||
{id: 'badges.label.name', defaultMessage: 'Название:'},
|
||||
) + ' ' + badge.name;
|
||||
|
||||
const descRow = intl.formatMessage(
|
||||
{id: 'badges.label.description', defaultMessage: 'Описание:'},
|
||||
) + ' ' + desc;
|
||||
|
||||
if (count > 1) {
|
||||
const countRow = intl.formatMessage(
|
||||
{id: 'badges.label.count', defaultMessage: 'Количество: {count}'},
|
||||
{count},
|
||||
);
|
||||
return <>{nameRow}{'\n'}{descRow}{'\n'}{countRow}</>;
|
||||
}
|
||||
|
||||
const time = new Date(badge.time);
|
||||
const grantedBy = intl.formatMessage(
|
||||
{id: 'badges.label.granted_by', defaultMessage: 'Выдал: {username}'},
|
||||
{username: badge.granted_by_name},
|
||||
);
|
||||
const grantedAt = intl.formatMessage(
|
||||
{id: 'badges.label.granted_at', defaultMessage: 'Выдан: {date}'},
|
||||
{date: intl.formatDate(time, {day: '2-digit', month: '2-digit', year: 'numeric'})},
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{nameRow}{'\n'}
|
||||
|
vladimir.khablak
commented
такой конструкции я еще не видел, жестка такой конструкции я еще не видел, жестка
kirill.moos
commented
Что тут происходит?) Согласен с Владмиром Что тут происходит?) Согласен с Владмиром
|
||||
{descRow}{'\n'}
|
||||
{badge.reason && (
|
||||
<>
|
||||
{intl.formatMessage(
|
||||
{id: 'badges.label.reason', defaultMessage: 'Причина: {reason}'},
|
||||
{reason: badge.reason},
|
||||
)}{'\n'}
|
||||
</>
|
||||
)}
|
||||
{grantedBy}{'\n'}
|
||||
{grantedAt}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BadgeTooltip;
|
||||
@ -1,49 +1 @@
|
||||
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
|
||||
// See License for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||
|
||||
import {GlobalState} from 'mattermost-redux/types/store';
|
||||
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||
|
||||
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
|
||||
|
||||
import {getShowRHS} from 'selectors';
|
||||
import {RHSState} from 'types/general';
|
||||
import {BadgeID} from 'types/badges';
|
||||
|
||||
import BadgeList from './badge_list';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
openRHS: getShowRHS(state),
|
||||
currentUserID: getCurrentUserId(state),
|
||||
debug: state,
|
||||
};
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
setRHSView: (view: RHSState) => Promise<void>;
|
||||
setRHSBadge: (id: BadgeID | null) => Promise<void>;
|
||||
setRHSUser: (id: string | null) => Promise<void>;
|
||||
openGrant: (user?: string, badge?: string) => Promise<void>;
|
||||
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch: Dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators<ActionCreatorsMapObject, Actions>({
|
||||
setRHSView,
|
||||
setRHSBadge,
|
||||
setRHSUser,
|
||||
openGrant,
|
||||
getCustomEmojisByName,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(BadgeList);
|
||||
export {default} from './badge_list';
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React, {ReactNode, useState, useRef, useEffect} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
25
webapp/src/components/utils/badge_list_utils.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {BadgeID, UserBadge} from 'types/badges';
|
||||
|
||||
export type BadgeGroup = {
|
||||
badge: UserBadge;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const MAX_DESC_LENGTH = 40;
|
||||
|
||||
export function groupBadges(badges: UserBadge[]): BadgeGroup[] {
|
||||
const map = new Map<BadgeID, BadgeGroup>();
|
||||
for (const badge of badges) {
|
||||
const existing = map.get(badge.id);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
} else {
|
||||
map.set(badge.id, {badge, count: 1});
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function truncateText(text: string): string {
|
||||
return text.length > MAX_DESC_LENGTH ? text.slice(0, MAX_DESC_LENGTH) + '...' : text;
|
||||
}
|
||||
@ -8,10 +8,21 @@ export const RHS_STATE_MY: RHSState = 'my';
|
||||
export const RHS_STATE_OTHER: RHSState = 'other';
|
||||
export const RHS_STATE_ALL: RHSState = 'all';
|
||||
export const RHS_STATE_DETAIL: RHSState = 'detail';
|
||||
export const RHS_STATE_TYPES: RHSState = 'types';
|
||||
export const RHS_STATE_TYPE_BADGES: RHSState = 'type_badges';
|
||||
|
||||
export const initialState: PluginState = {
|
||||
showRHS: null,
|
||||
rhsView: RHS_STATE_MY,
|
||||
prevRhsView: RHS_STATE_MY,
|
||||
rhsBadge: null,
|
||||
rhsUser: null,
|
||||
rhsTypeId: null,
|
||||
rhsTypeName: null,
|
||||
createBadgeModalVisible: false,
|
||||
editBadgeModalData: null,
|
||||
createTypeModalVisible: false,
|
||||
editTypeModalData: null,
|
||||
grantModalData: null,
|
||||
subscriptionModalData: null,
|
||||
};
|
||||
|
||||
@ -17,6 +17,10 @@ import {useSelector} from 'react-redux';
|
||||
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
|
||||
|
||||
import RHSComponent from 'components/rhs';
|
||||
import BadgeModal from 'components/badge_modal';
|
||||
import TypeModal from 'components/type_modal';
|
||||
import GrantModal from 'components/grant_modal';
|
||||
import SubscriptionModal from 'components/subscription_modal';
|
||||
|
||||
import ChannelHeaderButton from 'components/channel_header_button';
|
||||
|
||||
@ -60,6 +64,11 @@ export default class Plugin {
|
||||
|
||||
registry.registerPopoverUserAttributesComponent(WrappedBadgeList);
|
||||
|
||||
registry.registerRootComponent(withIntl(BadgeModal));
|
||||
registry.registerRootComponent(withIntl(TypeModal));
|
||||
registry.registerRootComponent(withIntl(GrantModal));
|
||||
registry.registerRootComponent(withIntl(SubscriptionModal));
|
||||
|
||||
const locale = getCurrentUser(store.getState())?.locale || 'ru';
|
||||
const messages = getTranslations(locale);
|
||||
|
||||
|
||||
@ -23,6 +23,15 @@ function rhsView(state = RHS_STATE_MY, action: GenericAction) {
|
||||
}
|
||||
}
|
||||
|
||||
function prevRhsView(state = RHS_STATE_MY, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.RECEIVED_RHS_VIEW:
|
||||
return action.prevView || state;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function rhsUser(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.RECEIVED_RHS_USER:
|
||||
@ -41,9 +50,102 @@ function rhsBadge(state = null, action: GenericAction) {
|
||||
}
|
||||
}
|
||||
|
||||
function rhsTypeId(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.RECEIVED_RHS_TYPE:
|
||||
return action.data.typeId;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function rhsTypeName(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.RECEIVED_RHS_TYPE:
|
||||
return action.data.typeName;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function createBadgeModalVisible(state = false, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_CREATE_BADGE_MODAL:
|
||||
return true;
|
||||
case ActionTypes.CLOSE_CREATE_BADGE_MODAL:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function editBadgeModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_EDIT_BADGE_MODAL:
|
||||
return action.data;
|
||||
case ActionTypes.CLOSE_EDIT_BADGE_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function createTypeModalVisible(state = false, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_CREATE_TYPE_MODAL:
|
||||
return true;
|
||||
case ActionTypes.CLOSE_CREATE_TYPE_MODAL:
|
||||
return false;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function editTypeModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_EDIT_TYPE_MODAL:
|
||||
return action.data;
|
||||
case ActionTypes.CLOSE_EDIT_TYPE_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function grantModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_GRANT_MODAL:
|
||||
return action.data || {};
|
||||
case ActionTypes.CLOSE_GRANT_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function subscriptionModalData(state = null, action: GenericAction) {
|
||||
switch (action.type) {
|
||||
case ActionTypes.OPEN_SUBSCRIPTION_MODAL:
|
||||
return action.data;
|
||||
case ActionTypes.CLOSE_SUBSCRIPTION_MODAL:
|
||||
return null;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
showRHS,
|
||||
rhsView,
|
||||
prevRhsView,
|
||||
rhsUser,
|
||||
rhsBadge,
|
||||
rhsTypeId,
|
||||
rhsTypeName,
|
||||
createBadgeModalVisible,
|
||||
editBadgeModalData,
|
||||
createTypeModalVisible,
|
||||
editTypeModalData,
|
||||
grantModalData,
|
||||
subscriptionModalData,
|
||||
});
|
||||
|
||||
@ -30,6 +30,13 @@ export const getRHSView = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
export const getPrevRHSView = createSelector(
|
||||
|
vladimir.khablak
commented
разве первым аргументом не должна идти строка? типо 'getPrevRHSView' или что-то такое? разве первым аргументом не должна идти строка? типо 'getPrevRHSView' или что-то такое?
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.prevRhsView;
|
||||
},
|
||||
);
|
||||
|
||||
export const getRHSUser = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
@ -43,3 +50,59 @@ export const getRHSBadge = createSelector(
|
||||
return state.rhsBadge;
|
||||
},
|
||||
);
|
||||
|
||||
export const getRHSTypeId = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.rhsTypeId;
|
||||
},
|
||||
);
|
||||
|
||||
export const getRHSTypeName = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.rhsTypeName;
|
||||
},
|
||||
);
|
||||
|
||||
export const isCreateBadgeModalVisible = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.createBadgeModalVisible;
|
||||
},
|
||||
);
|
||||
|
||||
export const getEditBadgeModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.editBadgeModalData;
|
||||
},
|
||||
);
|
||||
|
||||
export const isCreateTypeModalVisible = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.createTypeModalVisible;
|
||||
},
|
||||
);
|
||||
|
||||
export const getEditTypeModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.editTypeModalData;
|
||||
},
|
||||
);
|
||||
|
||||
export const getGrantModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.grantModalData;
|
||||
},
|
||||
);
|
||||
|
||||
export const getSubscriptionModalData = createSelector(
|
||||
getPluginState,
|
||||
(state) => {
|
||||
return state.subscriptionModalData;
|
||||
},
|
||||
);
|
||||
|
||||
@ -29,6 +29,7 @@ export type BadgeDetails = Badge & {
|
||||
owners: OwnershipList;
|
||||
created_by_username: string;
|
||||
type_name: string;
|
||||
can_edit: boolean;
|
||||
}
|
||||
export type AllBadgesBadge = Badge & {
|
||||
granted: number;
|
||||
@ -42,4 +43,96 @@ export type BadgeTypeDefinition = {
|
||||
id: BadgeType;
|
||||
name: string;
|
||||
frame: string;
|
||||
created_by: string;
|
||||
created_by_username: string;
|
||||
can_grant: PermissionScheme;
|
||||
can_create: PermissionScheme;
|
||||
badge_count: number;
|
||||
is_default: boolean;
|
||||
allowlist_can_create: string;
|
||||
allowlist_can_grant: string;
|
||||
}
|
||||
|
||||
export type PermissionScheme = {
|
||||
everyone: boolean;
|
||||
roles: Record<string, boolean>;
|
||||
allow_list: Record<string, boolean>;
|
||||
block_list: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export type GetTypesResponse = {
|
||||
types: BadgeTypeDefinition[];
|
||||
can_create_type: boolean;
|
||||
can_edit_type: boolean;
|
||||
}
|
||||
|
||||
export type TypeFormData = {
|
||||
name: string;
|
||||
everyoneCanCreate: boolean;
|
||||
everyoneCanGrant: boolean;
|
||||
allowlistCanCreate: string;
|
||||
allowlistCanGrant: string;
|
||||
}
|
||||
|
||||
export type BadgeFormData = {
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
badgeType: string;
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export type CreateBadgeRequest = {
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
channel_id?: string;
|
||||
}
|
||||
|
||||
export type UpdateBadgeRequest = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
image: string;
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export type CreateTypeRequest = {
|
||||
name: string;
|
||||
everyone_can_create: boolean;
|
||||
everyone_can_grant: boolean;
|
||||
allowlist_can_create: string;
|
||||
allowlist_can_grant: string;
|
||||
channel_id?: string;
|
||||
}
|
||||
|
||||
export type UpdateTypeRequest = {
|
||||
id: string;
|
||||
name: string;
|
||||
everyone_can_create: boolean;
|
||||
everyone_can_grant: boolean;
|
||||
allowlist_can_create: string;
|
||||
allowlist_can_grant: string;
|
||||
}
|
||||
|
||||
export type GrantBadgeRequest = {
|
||||
badge_id: string;
|
||||
user_id: string;
|
||||
reason: string;
|
||||
notify_here: boolean;
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
export type SubscriptionRequest = {
|
||||
type_id: string;
|
||||
channel_id: string;
|
||||
}
|
||||
|
||||
export type RevokeOwnershipRequest = {
|
||||
badge_id: string;
|
||||
user_id: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
@ -1,10 +1,28 @@
|
||||
import {BadgeID} from './badges';
|
||||
import {BadgeDetails, BadgeID, BadgeTypeDefinition} from './badges';
|
||||
|
||||
export type RHSState = string;
|
||||
|
||||
export type GrantModalData = {
|
||||
prefillUser?: string;
|
||||
prefillBadgeId?: string;
|
||||
}
|
||||
|
||||
export type SubscriptionModalData = {
|
||||
mode: 'create' | 'delete';
|
||||
}
|
||||
|
||||
export type PluginState = {
|
||||
showRHS: (() => void)| null;
|
||||
rhsView: RHSState;
|
||||
prevRhsView: RHSState;
|
||||
rhsUser: string | null;
|
||||
rhsBadge: BadgeID | null;
|
||||
rhsTypeId: number | null;
|
||||
rhsTypeName: string | null;
|
||||
createBadgeModalVisible: boolean;
|
||||
editBadgeModalData: BadgeDetails | null;
|
||||
createTypeModalVisible: boolean;
|
||||
editTypeModalData: BadgeTypeDefinition | null;
|
||||
grantModalData: GrantModalData | null;
|
||||
subscriptionModalData: SubscriptionModalData | null;
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ export interface PluginRegistry {
|
||||
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode);
|
||||
registerTranslations(getTranslationsForLocale: (locale: string) => Record<string, string>): void;
|
||||
registerAdminConsoleCustomSetting(key: string, component: React.ElementType, options?: {showTitle: boolean}): void;
|
||||
registerRootComponent(component: React.ElementType): void;
|
||||
|
||||
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
|
||||
}
|
||||
|
||||
29
webapp/src/utils/helpers.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {UserProfile} from 'mattermost-redux/types/users';
|
||||
|
||||
export function getUserDisplayName(user: UserProfile): string {
|
||||
if (user.nickname) {
|
||||
return user.nickname;
|
||||
}
|
||||
if (user.first_name || user.last_name) {
|
||||
return `${user.first_name} ${user.last_name}`.trim();
|
||||
}
|
||||
return user.username;
|
||||
}
|
||||
|
||||
export function debounce<T extends(...args: any[]) => void>(fn: T, delay: number): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return ((...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
}) as unknown as T;
|
||||
}
|
||||
|
||||
export function getServerErrorId(err: unknown): string {
|
||||
const msg = (err as {message?: string})?.message || '';
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
return parsed.id || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -48,6 +48,13 @@ module.exports = {
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.mjs$/,
|
||||
include: /node_modules/,
|
||||
resolve: {
|
||||
fullySpecified: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(js|jsx|ts|tsx)$/,
|
||||
exclude: /node_modules/,
|
||||
@ -108,6 +115,7 @@ module.exports = {
|
||||
path: path.join(__dirname, '/dist'),
|
||||
publicPath: '/',
|
||||
filename: 'main.js',
|
||||
hashFunction: 'xxhash64',
|
||||
},
|
||||
devtool,
|
||||
mode,
|
||||
|
||||
495
webapp/yarn.lock
@ -1768,33 +1768,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@giphy/js-fetch-api@npm:^5.1.0":
|
||||
version: 5.7.0
|
||||
resolution: "@giphy/js-fetch-api@npm:5.7.0"
|
||||
dependencies:
|
||||
"@giphy/js-types": "npm:*"
|
||||
"@giphy/js-util": "npm:*"
|
||||
checksum: 10c0/af1990c49ed4d633be04e497f6575e4f798c61348cfca9907f74a8450746bb6ab7336b53eea99e647b902076016d994264979bd09a9aacaa85d40cd610a525ac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@giphy/js-types@npm:*":
|
||||
version: 5.1.0
|
||||
resolution: "@giphy/js-types@npm:5.1.0"
|
||||
checksum: 10c0/8a76b9fd72d10d47486f26902a2fdc083712b4e0582bb2b27698b2c9c58fd3ecfa07d14ac30bcb80ec0f2ec35b3de7f7791d9d9751b0e6cb7d6fa5c39d52479f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@giphy/js-util@npm:*":
|
||||
version: 5.2.0
|
||||
resolution: "@giphy/js-util@npm:5.2.0"
|
||||
dependencies:
|
||||
"@giphy/js-types": "npm:*"
|
||||
uuid: "npm:^9.0.0"
|
||||
checksum: 10c0/0782a4fa1d7b037b4010f76966f5b8347c5732f57516d191471d746cd733f2d212f7461c4ff613961e51cbd65584a1f5e7202dbe5ae2231978b5c52f51a2e7f7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@isaacs/balanced-match@npm:^4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "@isaacs/balanced-match@npm:4.0.1"
|
||||
@ -2395,7 +2368,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/eslint-scope@npm:^3.7.0":
|
||||
"@types/eslint-scope@npm:^3.7.7":
|
||||
version: 3.7.7
|
||||
resolution: "@types/eslint-scope@npm:3.7.7"
|
||||
dependencies:
|
||||
@ -2415,20 +2388,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:*":
|
||||
"@types/estree@npm:*, @types/estree@npm:^1.0.8":
|
||||
version: 1.0.8
|
||||
resolution: "@types/estree@npm:1.0.8"
|
||||
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/estree@npm:^0.0.47":
|
||||
version: 0.0.47
|
||||
resolution: "@types/estree@npm:0.0.47"
|
||||
checksum: 10c0/f4541984097640b8fd594ce5870c7cc4116d0be50caa5f992ab1c6731831cb2083deb8aac4644ef83d638bb0321ac5e42cd315d45c3c62f5a3e54fa10b59c6fe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/graceful-fs@npm:^4.1.2":
|
||||
version: 4.1.9
|
||||
resolution: "@types/graceful-fs@npm:4.1.9"
|
||||
@ -2491,7 +2457,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.3, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
|
||||
"@types/json-schema@npm:*, @types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.3, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9":
|
||||
version: 7.0.15
|
||||
resolution: "@types/json-schema@npm:7.0.15"
|
||||
checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db
|
||||
@ -2639,25 +2605,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:*":
|
||||
version: 19.2.14
|
||||
resolution: "@types/react@npm:19.2.14"
|
||||
dependencies:
|
||||
csstype: "npm:^3.2.2"
|
||||
checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:16 || 17 || 18":
|
||||
version: 18.3.28
|
||||
resolution: "@types/react@npm:18.3.28"
|
||||
dependencies:
|
||||
"@types/prop-types": "npm:*"
|
||||
csstype: "npm:^3.2.2"
|
||||
checksum: 10c0/683e19cd12b5c691215529af2e32b5ffbaccae3bf0ba93bfafa0e460e8dfee18423afed568be2b8eadf4b837c3749dd296a4f64e2d79f68fa66962c05f5af661
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react@npm:17.0.3":
|
||||
version: 17.0.3
|
||||
resolution: "@types/react@npm:17.0.3"
|
||||
@ -2799,154 +2746,154 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/ast@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/ast@npm:1.11.0"
|
||||
"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/ast@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/helper-numbers": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
checksum: 10c0/8cf4369381f5212fa04e9e42517c1dbfb5e71c8612226ff48924f2816dc48cec025ee753f4ff56258d1be0ee9f90f409f8ccea7ee738411f81677acf9fabe9e5
|
||||
"@webassemblyjs/helper-numbers": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
checksum: 10c0/67a59be8ed50ddd33fbb2e09daa5193ac215bf7f40a9371be9a0d9797a114d0d1196316d2f3943efdb923a3d809175e1563a3cb80c814fb8edccd1e77494972b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/floating-point-hex-parser@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.11.0"
|
||||
checksum: 10c0/8e14aa3e0eaecbfe193660af7b9feb0724797a503975fdd8eff10ce8e5da23c4faf3073248f194848eb283d4cf63d57b218d15f2a2203aef7d02dea598e36a54
|
||||
"@webassemblyjs/floating-point-hex-parser@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.13.2"
|
||||
checksum: 10c0/0e88bdb8b50507d9938be64df0867f00396b55eba9df7d3546eb5dc0ca64d62e06f8d881ec4a6153f2127d0f4c11d102b6e7d17aec2f26bb5ff95a5e60652412
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-api-error@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-api-error@npm:1.11.0"
|
||||
checksum: 10c0/6a2cebd7f7846d36d1bbb213ce2135d5fd0d6b16617619108c95a993a39470a2238adcc6c1d56b569212b8a711b9559d758f6a87bc16c80c383b70142961e8d7
|
||||
"@webassemblyjs/helper-api-error@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/helper-api-error@npm:1.13.2"
|
||||
checksum: 10c0/31be497f996ed30aae4c08cac3cce50c8dcd5b29660383c0155fce1753804fc55d47fcba74e10141c7dd2899033164e117b3bcfcda23a6b043e4ded4f1003dfb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-buffer@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-buffer@npm:1.11.0"
|
||||
checksum: 10c0/73037720ffa6a5e12444cf753eff780fcceba0b0f654b531a4d24d93882c9e77cd92455602837dc4cd040f11228ef674ecca55d40e05292d187eed9653ebf8fd
|
||||
"@webassemblyjs/helper-buffer@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/helper-buffer@npm:1.14.1"
|
||||
checksum: 10c0/0d54105dc373c0fe6287f1091e41e3a02e36cdc05e8cf8533cdc16c59ff05a646355415893449d3768cda588af451c274f13263300a251dc11a575bc4c9bd210
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-numbers@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-numbers@npm:1.11.0"
|
||||
"@webassemblyjs/helper-numbers@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/helper-numbers@npm:1.13.2"
|
||||
dependencies:
|
||||
"@webassemblyjs/floating-point-hex-parser": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.11.0"
|
||||
"@webassemblyjs/floating-point-hex-parser": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.13.2"
|
||||
"@xtuc/long": "npm:4.2.2"
|
||||
checksum: 10c0/2dab8e53898a89363e778b54439cf9a8c1c2c31cddb99b54e642b2947e9f9949aed42d3a0f3bb57238a56b8205140de25a4ee07f78c605458f38b7effd9462be
|
||||
checksum: 10c0/9c46852f31b234a8fb5a5a9d3f027bc542392a0d4de32f1a9c0075d5e8684aa073cb5929b56df565500b3f9cc0a2ab983b650314295b9bf208d1a1651bfc825a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-wasm-bytecode@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.11.0"
|
||||
checksum: 10c0/947e2132d1137d51e93d6ccd7380c35ad98e9e76e0bb203d341c00b8d6ad643792c0d584a89f415edca467327ae28305cd8fb20a04df37d804c0438c71e5b1f0
|
||||
"@webassemblyjs/helper-wasm-bytecode@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.13.2"
|
||||
checksum: 10c0/c4355d14f369b30cf3cbdd3acfafc7d0488e086be6d578e3c9780bd1b512932352246be96e034e2a7fcfba4f540ec813352f312bfcbbfe5bcfbf694f82ccc682
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/helper-wasm-section@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/helper-wasm-section@npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-section@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/helper-wasm-section@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.11.0"
|
||||
checksum: 10c0/94472ea408338be95a5b879e8e360d00ec8f5d1f079e8f18d8d4d11baf7bad4c11a83711605e09b224749e35f54b1d9ba97d8e751c005641979bd6c57a977a8a
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.14.1"
|
||||
checksum: 10c0/1f9b33731c3c6dbac3a9c483269562fa00d1b6a4e7133217f40e83e975e636fd0f8736e53abd9a47b06b66082ecc976c7384391ab0a68e12d509ea4e4b948d64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/ieee754@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/ieee754@npm:1.11.0"
|
||||
"@webassemblyjs/ieee754@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/ieee754@npm:1.13.2"
|
||||
dependencies:
|
||||
"@xtuc/ieee754": "npm:^1.2.0"
|
||||
checksum: 10c0/c036fe4c933e77caaaa0850bfa87e3b9be0416a87c8915e62511fbebe98b0b21425bc2910f94839f80c15fd1d1f7bcf858ac7ea51cbfd28d3e6f93f79a933ae1
|
||||
checksum: 10c0/2e732ca78c6fbae3c9b112f4915d85caecdab285c0b337954b180460290ccd0fb00d2b1dc4bb69df3504abead5191e0d28d0d17dfd6c9d2f30acac8c4961c8a7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/leb128@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/leb128@npm:1.11.0"
|
||||
"@webassemblyjs/leb128@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/leb128@npm:1.13.2"
|
||||
dependencies:
|
||||
"@xtuc/long": "npm:4.2.2"
|
||||
checksum: 10c0/efd79fc32923e857907e0ab9cdc24b2694f79f09f820fe031e64c055db0d39450310e39cee220b55a9c7dc899715030c4722dbfa65cdad3ef7b4ac931a1a2f6e
|
||||
checksum: 10c0/dad5ef9e383c8ab523ce432dfd80098384bf01c45f70eb179d594f85ce5db2f80fa8c9cba03adafd85684e6d6310f0d3969a882538975989919329ac4c984659
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/utf8@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/utf8@npm:1.11.0"
|
||||
checksum: 10c0/86cb717f4b174c8ad4a4ee720d85f2e926bd5a7e3a996fc47e2cd5097547be24f2406f46b83775d820c4d6dca68fc4f447a5ce40267c591126967d2087d13773
|
||||
"@webassemblyjs/utf8@npm:1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "@webassemblyjs/utf8@npm:1.13.2"
|
||||
checksum: 10c0/d3fac9130b0e3e5a1a7f2886124a278e9323827c87a2b971e6d0da22a2ba1278ac9f66a4f2e363ecd9fac8da42e6941b22df061a119e5c0335f81006de9ee799
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-edit@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-edit@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-edit@npm:^1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-edit@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-section": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-opt": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.11.0"
|
||||
"@webassemblyjs/wast-printer": "npm:1.11.0"
|
||||
checksum: 10c0/123b8368ec13b7645f190eba24ac0eb0c1a69878c4d6fee3be5f640cf124a1889244e55a28b07196a16a5dff43a3c2d6afebf819794d7c00f29d74309adea77e
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-wasm-section": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-opt": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.14.1"
|
||||
"@webassemblyjs/wast-printer": "npm:1.14.1"
|
||||
checksum: 10c0/5ac4781086a2ca4b320bdbfd965a209655fe8a208ca38d89197148f8597e587c9a2c94fb6bd6f1a7dbd4527c49c6844fcdc2af981f8d793a97bf63a016aa86d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-gen@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-gen@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-gen@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/ieee754": "npm:1.11.0"
|
||||
"@webassemblyjs/leb128": "npm:1.11.0"
|
||||
"@webassemblyjs/utf8": "npm:1.11.0"
|
||||
checksum: 10c0/4783c98b2852ee9b2477c48a61861943b25a4e1fc9e93677919ede593790d85026ac7d8e92470cb6b864acede89a28f8395118bdaa60946faec8deec22e92c4f
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/ieee754": "npm:1.13.2"
|
||||
"@webassemblyjs/leb128": "npm:1.13.2"
|
||||
"@webassemblyjs/utf8": "npm:1.13.2"
|
||||
checksum: 10c0/d678810d7f3f8fecb2e2bdadfb9afad2ec1d2bc79f59e4711ab49c81cec578371e22732d4966f59067abe5fba8e9c54923b57060a729d28d408e608beef67b10
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-opt@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-opt@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-opt@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-opt@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.11.0"
|
||||
checksum: 10c0/25c83c7a3cd923d413e0bae8df01e0a43d381314936a8473bdcff05a968d9398c4b7e42b8d86c1aaefef52fbfac5f52acab72822208bb7cff31c2d9d6c53de8c
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-buffer": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-gen": "npm:1.14.1"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.14.1"
|
||||
checksum: 10c0/515bfb15277ee99ba6b11d2232ddbf22aed32aad6d0956fe8a0a0a004a1b5a3a277a71d9a3a38365d0538ac40d1b7b7243b1a244ad6cd6dece1c1bb2eb5de7ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wasm-parser@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wasm-parser@npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser@npm:1.14.1, @webassemblyjs/wasm-parser@npm:^1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wasm-parser@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.11.0"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.11.0"
|
||||
"@webassemblyjs/ieee754": "npm:1.11.0"
|
||||
"@webassemblyjs/leb128": "npm:1.11.0"
|
||||
"@webassemblyjs/utf8": "npm:1.11.0"
|
||||
checksum: 10c0/b63ae587d841c5545a232e4acd57f61e1f7e5e24ce842b3948d841ce4d38c2a5f51918a71448e4c55454721eaab5dce28a75c74e2229ada1c115e30ee65896cc
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@webassemblyjs/helper-api-error": "npm:1.13.2"
|
||||
"@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2"
|
||||
"@webassemblyjs/ieee754": "npm:1.13.2"
|
||||
"@webassemblyjs/leb128": "npm:1.13.2"
|
||||
"@webassemblyjs/utf8": "npm:1.13.2"
|
||||
checksum: 10c0/95427b9e5addbd0f647939bd28e3e06b8deefdbdadcf892385b5edc70091bf9b92fa5faac3fce8333554437c5d85835afef8c8a7d9d27ab6ba01ffab954db8c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webassemblyjs/wast-printer@npm:1.11.0":
|
||||
version: 1.11.0
|
||||
resolution: "@webassemblyjs/wast-printer@npm:1.11.0"
|
||||
"@webassemblyjs/wast-printer@npm:1.14.1":
|
||||
version: 1.14.1
|
||||
resolution: "@webassemblyjs/wast-printer@npm:1.14.1"
|
||||
dependencies:
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/ast": "npm:1.14.1"
|
||||
"@xtuc/long": "npm:4.2.2"
|
||||
checksum: 10c0/05fc5cc9a167d36df50a825be77da845d0834f0f819ae8d36cb2fd2aa1f524012c01ee9f463bd251e39b6bcf1f79e327fc444775e74f4af578fdd5794c9d97f2
|
||||
checksum: 10c0/8d7768608996a052545251e896eac079c98e0401842af8dd4de78fba8d90bd505efb6c537e909cd6dae96e09db3fa2e765a6f26492553a675da56e2db51f9d24
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3021,6 +2968,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn-import-phases@npm:^1.0.3":
|
||||
version: 1.0.4
|
||||
resolution: "acorn-import-phases@npm:1.0.4"
|
||||
peerDependencies:
|
||||
acorn: ^8.14.0
|
||||
checksum: 10c0/338eb46fc1aed5544f628344cb9af189450b401d152ceadbf1f5746901a5d923016cd0e7740d5606062d374fdf6941c29bb515d2bd133c4f4242d5d4cd73a3c7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn-jsx@npm:^5.3.1":
|
||||
version: 5.3.2
|
||||
resolution: "acorn-jsx@npm:5.3.2"
|
||||
@ -3046,7 +3002,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.0.4, acorn@npm:^8.15.0, acorn@npm:^8.2.4":
|
||||
"acorn@npm:^8.15.0, acorn@npm:^8.2.4":
|
||||
version: 8.15.0
|
||||
resolution: "acorn@npm:8.15.0"
|
||||
bin:
|
||||
@ -3055,6 +3011,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.16.0":
|
||||
version: 8.16.0
|
||||
resolution: "acorn@npm:8.16.0"
|
||||
bin:
|
||||
acorn: bin/acorn
|
||||
checksum: 10c0/c9c52697227661b68d0debaf972222d4f622aa06b185824164e153438afa7b08273432ca43ea792cadb24dada1d46f6f6bb1ef8de9956979288cc1b96bf9914e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"add-px-to-style@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "add-px-to-style@npm:1.0.0"
|
||||
@ -3481,18 +3446,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"babel-loader@npm:8.2.2":
|
||||
version: 8.2.2
|
||||
resolution: "babel-loader@npm:8.2.2"
|
||||
"babel-loader@npm:^8.3.0":
|
||||
version: 8.4.1
|
||||
resolution: "babel-loader@npm:8.4.1"
|
||||
dependencies:
|
||||
find-cache-dir: "npm:^3.3.1"
|
||||
loader-utils: "npm:^1.4.0"
|
||||
loader-utils: "npm:^2.0.4"
|
||||
make-dir: "npm:^3.1.0"
|
||||
schema-utils: "npm:^2.6.5"
|
||||
peerDependencies:
|
||||
"@babel/core": ^7.0.0
|
||||
webpack: ">=2"
|
||||
checksum: 10c0/c4e1e042af99a0c1ed83d4a9e3436c333b66a10459561eff921ebe274d75425b649cc317e1389a149931f29c5b03a5a46acc1daeb849d10582ce2bb65f697a5f
|
||||
checksum: 10c0/efdca9c3ef502af58b923a32123d660c54fd0be125b7b64562c8a43bda0a3a55dac0db32331674104e7e5184061b75c3a0e395b2c5ccdc7cb2125dd9ec7108d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -3835,7 +3800,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"browserslist@npm:^4.14.5, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1":
|
||||
"browserslist@npm:^4.24.0, browserslist@npm:^4.28.1":
|
||||
version: 4.28.1
|
||||
resolution: "browserslist@npm:4.28.1"
|
||||
dependencies:
|
||||
@ -4549,7 +4514,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"csstype@npm:^3.0.2, csstype@npm:^3.2.2":
|
||||
"csstype@npm:^3.0.2":
|
||||
version: 3.2.3
|
||||
resolution: "csstype@npm:3.2.3"
|
||||
checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce
|
||||
@ -4966,13 +4931,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"enhanced-resolve@npm:^5.8.0":
|
||||
version: 5.19.0
|
||||
resolution: "enhanced-resolve@npm:5.19.0"
|
||||
"enhanced-resolve@npm:^5.20.0":
|
||||
version: 5.20.0
|
||||
resolution: "enhanced-resolve@npm:5.20.0"
|
||||
dependencies:
|
||||
graceful-fs: "npm:^4.2.4"
|
||||
tapable: "npm:^2.3.0"
|
||||
checksum: 10c0/966b1dffb82d5f6a4d6a86e904e812104a999066aa29f9223040aaa751e7c453b462a3f5ef91f8bd4408131ff6f7f90651dd1c804bdcb7944e2099a9c2e45ee2
|
||||
checksum: 10c0/4ed5f38406fc9ad74c58a3d63b8215862243ab0ed6b0efc51ccdb72cdcedd3ac8638abe298680b279d7a83c3cb140e5eea7a5f8bd99696c74588f07ad89a95a7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -5213,10 +5178,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es-module-lexer@npm:^0.4.0":
|
||||
version: 0.4.1
|
||||
resolution: "es-module-lexer@npm:0.4.1"
|
||||
checksum: 10c0/6463778f04367979d7770cefb1969b6bfc277319e8437a39718b3516df16b1b496b725ceec96a2d24975837a15cf4d56838f16d9c8c7640ad13ad9c8f93ad6fc
|
||||
"es-module-lexer@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "es-module-lexer@npm:2.0.0"
|
||||
checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -5405,7 +5370,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
|
||||
"eslint-scope@npm:5.1.1, eslint-scope@npm:^5.0.0, eslint-scope@npm:^5.1.1":
|
||||
version: 5.1.1
|
||||
resolution: "eslint-scope@npm:5.1.1"
|
||||
dependencies:
|
||||
@ -5921,19 +5886,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"form-data@npm:^4.0.0":
|
||||
version: 4.0.5
|
||||
resolution: "form-data@npm:4.0.5"
|
||||
dependencies:
|
||||
asynckit: "npm:^0.4.0"
|
||||
combined-stream: "npm:^1.0.8"
|
||||
es-set-tostringtag: "npm:^2.1.0"
|
||||
hasown: "npm:^2.0.2"
|
||||
mime-types: "npm:^2.1.12"
|
||||
checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fragment-cache@npm:^0.2.1":
|
||||
version: 0.2.1
|
||||
resolution: "fragment-cache@npm:0.2.1"
|
||||
@ -6229,7 +6181,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
|
||||
"graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2
|
||||
@ -7800,14 +7752,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-parse-better-errors@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "json-parse-better-errors@npm:1.0.2"
|
||||
checksum: 10c0/2f1287a7c833e397c9ddd361a78638e828fc523038bb3441fd4fc144cfd2c6cd4963ffb9e207e648cf7b692600f1e1e524e965c32df5152120910e4903a47dcb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json-parse-even-better-errors@npm:^2.3.0":
|
||||
"json-parse-even-better-errors@npm:^2.3.0, json-parse-even-better-errors@npm:^2.3.1":
|
||||
version: 2.3.1
|
||||
resolution: "json-parse-even-better-errors@npm:2.3.1"
|
||||
checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3
|
||||
@ -7842,7 +7787,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"json5@npm:^1.0.1, json5@npm:^1.0.2":
|
||||
"json5@npm:^1.0.2":
|
||||
version: 1.0.2
|
||||
resolution: "json5@npm:1.0.2"
|
||||
dependencies:
|
||||
@ -7965,25 +7910,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loader-runner@npm:^4.2.0":
|
||||
"loader-runner@npm:^4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "loader-runner@npm:4.3.1"
|
||||
checksum: 10c0/a523b6329f114e0a98317158e30a7dfce044b731521be5399464010472a93a15ece44757d1eaed1d8845019869c5390218bc1c7c3110f4eeaef5157394486eac
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loader-utils@npm:^1.4.0":
|
||||
version: 1.4.2
|
||||
resolution: "loader-utils@npm:1.4.2"
|
||||
dependencies:
|
||||
big.js: "npm:^5.2.2"
|
||||
emojis-list: "npm:^3.0.0"
|
||||
json5: "npm:^1.0.1"
|
||||
checksum: 10c0/2b726088b5526f7605615e3e28043ae9bbd2453f4a85898e1151f3c39dbf7a2b65d09f3996bc588d92ac7e717ded529d3e1ea3ea42c433393be84a58234a2f53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loader-utils@npm:^2.0.0":
|
||||
"loader-utils@npm:^2.0.0, loader-utils@npm:^2.0.4":
|
||||
version: 2.0.4
|
||||
resolution: "loader-utils@npm:2.0.4"
|
||||
dependencies:
|
||||
@ -8062,20 +7996,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loop-plugin-sdk@https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz":
|
||||
version: 0.1.6
|
||||
resolution: "loop-plugin-sdk@https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz"
|
||||
dependencies:
|
||||
"@giphy/js-fetch-api": "npm:^5.1.0"
|
||||
form-data: "npm:^4.0.0"
|
||||
rudder-sdk-js: "npm:^2.41.0"
|
||||
serialize-error: "npm:^11.0.2"
|
||||
shallow-equals: "npm:^1.0.0"
|
||||
timezones.json: "npm:^1.7.1"
|
||||
checksum: 10c0/661ed3b99bb666a5fe024dadbbb2f1cf6d7d43bed3e94e87171e76985b31f82b8b4042358f30b60bd097e452185c957ec55fc03b15f45ac1ca70a6239fac1b71
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"loose-envify@npm:^1.1.0, loose-envify@npm:^1.4.0":
|
||||
version: 1.4.0
|
||||
resolution: "loose-envify@npm:1.4.0"
|
||||
@ -9648,6 +9568,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-virtuoso@npm:^4.18.1":
|
||||
version: 4.18.1
|
||||
resolution: "react-virtuoso@npm:4.18.1"
|
||||
peerDependencies:
|
||||
react: ">=16 || >=17 || >= 18 || >= 19"
|
||||
react-dom: ">=16 || >=17 || >= 18 || >=19"
|
||||
checksum: 10c0/ed17f580ad8d625ef9e0278ed12190bbadbacf7e39434047b7994e4967ad9d868b66eaee8a66a2890d2964d99d9b266a4657375488c19b1e58de252fb2e8d3e5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react@npm:17.0.2":
|
||||
version: 17.0.2
|
||||
resolution: "react@npm:17.0.2"
|
||||
@ -10206,7 +10136,7 @@ __metadata:
|
||||
"@typescript-eslint/parser": "npm:4.22.0"
|
||||
babel-eslint: "npm:10.1.0"
|
||||
babel-jest: "npm:26.6.3"
|
||||
babel-loader: "npm:8.2.2"
|
||||
babel-loader: "npm:^8.3.0"
|
||||
babel-plugin-typescript-to-proptypes: "npm:1.4.2"
|
||||
core-js: "npm:3.10.2"
|
||||
css-loader: "npm:5.2.4"
|
||||
@ -10223,19 +10153,19 @@ __metadata:
|
||||
jest: "npm:26.6.3"
|
||||
jest-canvas-mock: "npm:2.3.1"
|
||||
jest-junit: "npm:12.0.0"
|
||||
loop-plugin-sdk: "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz"
|
||||
mattermost-redux: "npm:5.33.1"
|
||||
memoize-one: "npm:^5.2.1"
|
||||
react: "npm:17.0.2"
|
||||
react-custom-scrollbars: "npm:^4.2.1"
|
||||
react-intl: "npm:6.8.9"
|
||||
react-redux: "npm:7.2.3"
|
||||
react-virtuoso: "npm:^4.18.1"
|
||||
redux: "npm:4.0.5"
|
||||
sass: "npm:1.86.0"
|
||||
sass-loader: "npm:11.0.1"
|
||||
style-loader: "npm:2.0.0"
|
||||
typescript: "npm:4.2.4"
|
||||
webpack: "npm:5.34.0"
|
||||
webpack: "npm:^5.54.0"
|
||||
webpack-cli: "npm:4.6.0"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@ -10264,13 +10194,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rudder-sdk-js@npm:^2.41.0":
|
||||
version: 2.52.8
|
||||
resolution: "rudder-sdk-js@npm:2.52.8"
|
||||
checksum: 10c0/62732c3402bf1858c1100b287bd72d7e54c293b308f2e96c633aa29dfd8758bbee50b1aa93011ea98d4960b8fb98d2ab88a69536b5fe35bd794e85d5cd4ae861
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"run-parallel@npm:^1.1.9":
|
||||
version: 1.2.0
|
||||
resolution: "run-parallel@npm:1.2.0"
|
||||
@ -10466,7 +10389,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"schema-utils@npm:^4.3.0":
|
||||
"schema-utils@npm:^4.3.0, schema-utils@npm:^4.3.3":
|
||||
version: 4.3.3
|
||||
resolution: "schema-utils@npm:4.3.3"
|
||||
dependencies:
|
||||
@ -10514,24 +10437,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"serialize-error@npm:^11.0.2":
|
||||
version: 11.0.3
|
||||
resolution: "serialize-error@npm:11.0.3"
|
||||
dependencies:
|
||||
type-fest: "npm:^2.12.2"
|
||||
checksum: 10c0/7263603883b8936650819f0fd5150d41427b317432678b21722c54b85367ae15b8552865eb7f3f39ba71a32a003730a2e2e971e6909431eb54db70a3ef8eca17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"serialize-javascript@npm:^6.0.2":
|
||||
version: 6.0.2
|
||||
resolution: "serialize-javascript@npm:6.0.2"
|
||||
dependencies:
|
||||
randombytes: "npm:^2.1.0"
|
||||
checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"set-blocking@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "set-blocking@npm:2.0.0"
|
||||
@ -10617,7 +10522,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"shallow-equals@npm:1.0.0, shallow-equals@npm:^1.0.0":
|
||||
"shallow-equals@npm:1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "shallow-equals@npm:1.0.0"
|
||||
checksum: 10c0/ba7c87947126fcfdd31d6c473c5785c235c385dcc045dd6b1543366b1e86aa8e8f69289346ffee0b13a845365fc6f3d21badd8c00e2b6c2b1d0e84d69bcf4487
|
||||
@ -10839,13 +10744,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-list-map@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "source-list-map@npm:2.0.1"
|
||||
checksum: 10c0/2e5e421b185dcd857f46c3c70e2e711a65d717b78c5f795e2e248c9d67757882ea989b80ebc08cf164eeeda5f4be8aa95d3b990225070b2daaaf3257c5958149
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.1":
|
||||
version: 1.2.1
|
||||
resolution: "source-map-js@npm:1.2.1"
|
||||
@ -11263,7 +11161,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tapable@npm:^2.1.1, tapable@npm:^2.3.0":
|
||||
"tapable@npm:^2.3.0":
|
||||
version: 2.3.0
|
||||
resolution: "tapable@npm:2.3.0"
|
||||
checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681
|
||||
@ -11293,14 +11191,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"terser-webpack-plugin@npm:^5.1.1":
|
||||
version: 5.3.16
|
||||
resolution: "terser-webpack-plugin@npm:5.3.16"
|
||||
"terser-webpack-plugin@npm:^5.3.17":
|
||||
version: 5.4.0
|
||||
resolution: "terser-webpack-plugin@npm:5.4.0"
|
||||
dependencies:
|
||||
"@jridgewell/trace-mapping": "npm:^0.3.25"
|
||||
jest-worker: "npm:^27.4.5"
|
||||
schema-utils: "npm:^4.3.0"
|
||||
serialize-javascript: "npm:^6.0.2"
|
||||
terser: "npm:^5.31.1"
|
||||
peerDependencies:
|
||||
webpack: ^5.1.0
|
||||
@ -11311,7 +11208,7 @@ __metadata:
|
||||
optional: true
|
||||
uglify-js:
|
||||
optional: true
|
||||
checksum: 10c0/39e37c5b3015c1a5354a3633f77235677bfa06eac2608ce26d258b1d1a74070a99910319a6f2f2c437eb61dc321f66434febe01d78e73fa96b4d4393b813f4cf
|
||||
checksum: 10c0/1feed4b9575af795dae6af0c8f0d76d6e1fb7b357b8628d90e834c23a651b918a58cdc48d0ae6c1f0581f74bc8169b33c3b8d049f2d2190bac4e310964e59fde
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@ -11363,13 +11260,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"timezones.json@npm:^1.7.1":
|
||||
version: 1.7.2
|
||||
resolution: "timezones.json@npm:1.7.2"
|
||||
checksum: 10c0/209da3d2334118790f57ad060de68ba799adde9df051a6428c348079ed0df20a753e190f85cceadab336f6b24a68302bcca84c895c70270126b15ea0bcde77cd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tinyglobby@npm:^0.2.12":
|
||||
version: 0.2.15
|
||||
resolution: "tinyglobby@npm:0.2.15"
|
||||
@ -11586,13 +11476,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"type-fest@npm:^2.12.2":
|
||||
version: 2.19.0
|
||||
resolution: "type-fest@npm:2.19.0"
|
||||
checksum: 10c0/a5a7ecf2e654251613218c215c7493574594951c08e52ab9881c9df6a6da0aeca7528c213c622bc374b4e0cb5c443aa3ab758da4e3c959783ce884c3194e12cb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-array-buffer@npm:^1.0.3":
|
||||
version: 1.0.3
|
||||
resolution: "typed-array-buffer@npm:1.0.3"
|
||||
@ -11895,15 +11778,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:^9.0.0":
|
||||
version: 9.0.1
|
||||
resolution: "uuid@npm:9.0.1"
|
||||
bin:
|
||||
uuid: dist/bin/uuid
|
||||
checksum: 10c0/1607dd32ac7fc22f2d8f77051e6a64845c9bce5cd3dd8aa0070c074ec73e666a1f63c7b4e0f4bf2bc8b9d59dc85a15e17807446d9d2b17c8485fbc2147b27f9b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"v8-compile-cache@npm:^2.0.3, v8-compile-cache@npm:^2.2.0":
|
||||
version: 2.4.0
|
||||
resolution: "v8-compile-cache@npm:2.4.0"
|
||||
@ -11966,7 +11840,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"watchpack@npm:^2.0.0":
|
||||
"watchpack@npm:^2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "watchpack@npm:2.5.1"
|
||||
dependencies:
|
||||
@ -12036,49 +11910,48 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webpack-sources@npm:^2.1.1":
|
||||
version: 2.3.1
|
||||
resolution: "webpack-sources@npm:2.3.1"
|
||||
dependencies:
|
||||
source-list-map: "npm:^2.0.1"
|
||||
source-map: "npm:^0.6.1"
|
||||
checksum: 10c0/caf56a9a478eca7e77feca2b6ddc7673f1384eb870280014b300c40cf42abca656f639ff58a8d55a889a92a810ae3c22e71e578aa38fde416e8c2e6827a6ddfd
|
||||
"webpack-sources@npm:^3.3.4":
|
||||
version: 3.3.4
|
||||
resolution: "webpack-sources@npm:3.3.4"
|
||||
checksum: 10c0/94a42508531338eb41939cf1d48a4a8a6db97f3a47e5453cff2133a68d3169ca779d4bcbe9dfed072ce16611959eba1e16f085bc2dc56714e1a1c1783fd661a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"webpack@npm:5.34.0":
|
||||
version: 5.34.0
|
||||
resolution: "webpack@npm:5.34.0"
|
||||
"webpack@npm:^5.54.0":
|
||||
version: 5.105.4
|
||||
resolution: "webpack@npm:5.105.4"
|
||||
dependencies:
|
||||
"@types/eslint-scope": "npm:^3.7.0"
|
||||
"@types/estree": "npm:^0.0.47"
|
||||
"@webassemblyjs/ast": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-edit": "npm:1.11.0"
|
||||
"@webassemblyjs/wasm-parser": "npm:1.11.0"
|
||||
acorn: "npm:^8.0.4"
|
||||
browserslist: "npm:^4.14.5"
|
||||
"@types/eslint-scope": "npm:^3.7.7"
|
||||
"@types/estree": "npm:^1.0.8"
|
||||
"@types/json-schema": "npm:^7.0.15"
|
||||
"@webassemblyjs/ast": "npm:^1.14.1"
|
||||
"@webassemblyjs/wasm-edit": "npm:^1.14.1"
|
||||
"@webassemblyjs/wasm-parser": "npm:^1.14.1"
|
||||
acorn: "npm:^8.16.0"
|
||||
acorn-import-phases: "npm:^1.0.3"
|
||||
browserslist: "npm:^4.28.1"
|
||||
chrome-trace-event: "npm:^1.0.2"
|
||||
enhanced-resolve: "npm:^5.8.0"
|
||||
es-module-lexer: "npm:^0.4.0"
|
||||
eslint-scope: "npm:^5.1.1"
|
||||
enhanced-resolve: "npm:^5.20.0"
|
||||
es-module-lexer: "npm:^2.0.0"
|
||||
eslint-scope: "npm:5.1.1"
|
||||
events: "npm:^3.2.0"
|
||||
glob-to-regexp: "npm:^0.4.1"
|
||||
graceful-fs: "npm:^4.2.4"
|
||||
json-parse-better-errors: "npm:^1.0.2"
|
||||
loader-runner: "npm:^4.2.0"
|
||||
graceful-fs: "npm:^4.2.11"
|
||||
json-parse-even-better-errors: "npm:^2.3.1"
|
||||
loader-runner: "npm:^4.3.1"
|
||||
mime-types: "npm:^2.1.27"
|
||||
neo-async: "npm:^2.6.2"
|
||||
schema-utils: "npm:^3.0.0"
|
||||
tapable: "npm:^2.1.1"
|
||||
terser-webpack-plugin: "npm:^5.1.1"
|
||||
watchpack: "npm:^2.0.0"
|
||||
webpack-sources: "npm:^2.1.1"
|
||||
schema-utils: "npm:^4.3.3"
|
||||
tapable: "npm:^2.3.0"
|
||||
terser-webpack-plugin: "npm:^5.3.17"
|
||||
watchpack: "npm:^2.5.1"
|
||||
webpack-sources: "npm:^3.3.4"
|
||||
peerDependenciesMeta:
|
||||
webpack-cli:
|
||||
optional: true
|
||||
bin:
|
||||
webpack: bin/webpack.js
|
||||
checksum: 10c0/10eb0d3eb666661398974518b26e3682b280f73e8d5720562d6e1f46169b4cfca627543adc1a449f574081cced47da2c3f570c59062a3b336a3e057bde67ba10
|
||||
checksum: 10c0/e9896d20bac351b119d59942b7efae5b117056ecf203acc0d1a84ecbf0a5a9a80ca733735f96bd163e3530be6ab7f615cd67e5320bd3c47d709c9bfe376c3280
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
По сути код в методах для crud дублиркется. Можно было бы сократить