mirror of
https://github.com/directus/directus.git
synced 2026-02-10 02:44:58 -05:00
Add notifications system and support user mentions in comments (#9861)
* v-menu de/activated onKeyDown. No List yet. * v-list * add user suggestion * uuids replaced * user-popover working * avatars flex row with usernames in suggestions * added space to end of uuid insert * autofocus + move caret to end of last insert * removed unnecessary setTimeout() * fixed filter 500 with ids * better fix * New translations en-US.yaml (French) (#9907) * New translations en-US.yaml (French) (#9912) * New translations en-US.yaml (French) (#9916) * New translations en-US.yaml (Russian) (#9918) * New translations en-US.yaml (Swedish) (#9920) * Email updates (#9921) * add from name for emails * updatd email template style * reset password email copy * updated logo to newest version * update invite email copy * decouple field template logic * push up styling * Start on new v-template-input * Add notifications API endpoints Squashed commit of the following: commit 9d86721ef795d03bc55693c0f99bde8e269d60e9 Merge: b4458c19f34131d06eAuthor: rijkvanzanten <rijkvanzanten@me.com> Date: Mon Nov 22 09:27:43 2021 -0500 Merge branch 'mentions' into mentions-api commit b4458c19f7c54f18fa415fc04c63642c2f5a17b0 Author: rijkvanzanten <rijkvanzanten@me.com> Date: Thu Nov 18 18:34:04 2021 -0500 Remove unused import commit e6a9d36bbfdf95cb18d29336da61ecb14b677934 Author: rijkvanzanten <rijkvanzanten@me.com> Date: Thu Nov 18 18:28:31 2021 -0500 Extract user mentions from comments commit b3e571a2daa287e1740a050096913662a57e9861 Merge: c93b833d2af2a6dd7fAuthor: rijkvanzanten <rijkvanzanten@me.com> Date: Thu Nov 18 17:39:52 2021 -0500 Merge branch 'mentions' into mentions-api commit c93b833d2b848e306c434b370d4e4e11967e85d0 Author: rijkvanzanten <rijkvanzanten@me.com> Date: Thu Nov 18 17:35:45 2021 -0500 Send emails w/ parsed MD commit 64bbd6596f20a07028d2387d60e33dfe4f91c032 Author: rijkvanzanten <rijkvanzanten@me.com> Date: Thu Nov 18 16:18:16 2021 -0500 Add notifications endpoint + permissions commit fba55c02dc9c303a38b1b958350684cccd3dd82c Author: rijkvanzanten <rijkvanzanten@me.com> Date: Thu Nov 18 15:33:28 2021 -0500 Add system data for notifications * push * Make v-template-input work * Add the two-way binding * submit button posting, not clearing text area * comment text area clearing on submit * Replace insertion correctly * Added scope support to LDAP group and user search (#9529) * Added scope support LDAP group and user search * Fixed linter screwing up my markdown * Update docs/configuration/config-options.md * Always return correct DN for user with sub scope * Fix indeterminate meta and schema property in advanded field creation (#9924) * Fix impossibility to save M2M (alterations not triggered) (#9992) * Fix alterations refactor * fix roles aggregate query (#9994) * Update iis.md (#9998) added the IIS URL Rewrite module as a requirement * New translations en-US.yaml (English, United Kingdom) (#10001) * Fix LDAP race condition (#9993) * Fix input ui * Revert changes to v-field-template * Update mentions permissions * Fix linter warnings * Optimize sending flow * Revert "Rename activity->notifications module (#9446)" This reverts commit428e5d4ea9. * Add notifications drawer * Update migrations * Improve constraints * Add email notifications toggle on users * Add docs, fix graphql support * Move caret-pos to devdeps * Remove unused new triggerKeyPressed system * Remove unused use-caret composable Co-authored-by: Nitwel <nitwel@arcor.de> Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com> Co-authored-by: Ben Haynes <ben@rngr.org> Co-authored-by: Aiden Foxx <aiden.foxx@sbab.se> Co-authored-by: Oreille <33065839+Oreilles@users.noreply.github.com> Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com> Co-authored-by: Paul Boudewijn <paul@helderinternet.nl>
This commit is contained in:
@@ -47,6 +47,7 @@ import VSlider from './v-slider/';
|
||||
import VSwitch from './v-switch/';
|
||||
import VTable from './v-table/';
|
||||
import VTabs, { VTab, VTabItem, VTabsItems } from './v-tabs/';
|
||||
import VTemplateInput from './v-template-input.vue';
|
||||
import VTextOverflow from './v-text-overflow.vue';
|
||||
import VTextarea from './v-textarea';
|
||||
import VUpload from './v-upload';
|
||||
@@ -103,6 +104,7 @@ export function registerComponents(app: App): void {
|
||||
app.component('VTable', VTable);
|
||||
app.component('VTabsItems', VTabsItems);
|
||||
app.component('VTabs', VTabs);
|
||||
app.component('VTemplateInput', VTemplateInput);
|
||||
app.component('VTextarea', VTextarea);
|
||||
app.component('VTextOverflow', VTextOverflow);
|
||||
app.component('VUpload', VUpload);
|
||||
|
||||
@@ -206,6 +206,7 @@ body {
|
||||
--content-padding: 16px;
|
||||
--content-padding-bottom: 32px;
|
||||
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
overflow: auto;
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export default defineComponent({
|
||||
trigger: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: (val: string) => ['hover', 'click'].includes(val),
|
||||
validator: (val: string) => ['hover', 'click', 'keyDown'].includes(val),
|
||||
},
|
||||
delay: {
|
||||
type: Number,
|
||||
|
||||
177
app/src/components/v-template-input.vue
Normal file
177
app/src/components/v-template-input.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div
|
||||
ref="input"
|
||||
class="v-template-input"
|
||||
:class="{ multiline }"
|
||||
contenteditable="true"
|
||||
tabindex="1"
|
||||
@input="processText"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref, watch, onMounted } from 'vue';
|
||||
import { position } from 'caret-pos';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
captureGroup: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
multiline: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
triggerCharacter: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Object as PropType<Record<string, string>>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'trigger', 'deactivate'],
|
||||
setup(props, { emit }) {
|
||||
const input = ref<HTMLDivElement>();
|
||||
|
||||
let hasTriggered = false;
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newText) => {
|
||||
if (!input.value) return;
|
||||
|
||||
if (newText !== input.value.innerText) {
|
||||
parseHTML(newText);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.modelValue && props.modelValue !== input.value!.innerText) {
|
||||
parseHTML(props.modelValue);
|
||||
}
|
||||
});
|
||||
|
||||
return { processText, input };
|
||||
|
||||
function processText(event: KeyboardEvent) {
|
||||
const input = event.target as HTMLDivElement;
|
||||
|
||||
const caretPos = position(input).pos;
|
||||
|
||||
const text = input.innerText ?? '';
|
||||
|
||||
let endPos = text.indexOf(' ', caretPos);
|
||||
if (endPos == -1) endPos = text.length;
|
||||
const result = /\S+$/.exec(text.slice(0, endPos));
|
||||
let word = result ? result[0] : null;
|
||||
if (word) word = word.replace(/[\s'";:,./?\\-]$/, '');
|
||||
|
||||
if (word?.startsWith(props.triggerCharacter)) {
|
||||
emit('trigger', { searchQuery: word.substring(props.triggerCharacter.length), caretPosition: caretPos });
|
||||
hasTriggered = true;
|
||||
} else {
|
||||
if (hasTriggered) {
|
||||
emit('deactivate');
|
||||
hasTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
parseHTML();
|
||||
|
||||
emit('update:modelValue', input.innerText);
|
||||
}
|
||||
|
||||
function parseHTML(innerText?: string) {
|
||||
if (!input.value) return;
|
||||
|
||||
let newHTML = innerText ?? input.value.innerHTML ?? '';
|
||||
|
||||
const caretPos = window.getSelection()?.rangeCount ? position(input.value).pos : 0;
|
||||
|
||||
const matches = newHTML.match(new RegExp(`${props.captureGroup}(?!</mark>)`, 'gi'));
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches ?? []) {
|
||||
newHTML = newHTML.replace(
|
||||
new RegExp(`(${match})(?!</mark>)`),
|
||||
` <mark class="preview" data-preview="${
|
||||
props.items[match.substring(props.triggerCharacter.length)]
|
||||
}" contenteditable="false">${match}</mark> `
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.value.innerHTML !== newHTML) {
|
||||
input.value.innerHTML = newHTML;
|
||||
const delta = newHTML.length - input.value.innerHTML.length;
|
||||
|
||||
const newPosition = caretPos + delta;
|
||||
|
||||
if (newPosition >= newHTML.length || newPosition < 0) {
|
||||
position(input.value, newHTML.length - 1);
|
||||
} else {
|
||||
position(input.value, caretPos + delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.v-template-input {
|
||||
position: relative;
|
||||
height: var(--input-height);
|
||||
padding: var(--input-padding);
|
||||
padding-bottom: 32px;
|
||||
overflow: hidden;
|
||||
color: var(--foreground-normal);
|
||||
font-family: var(--family-sans-serif);
|
||||
white-space: nowrap;
|
||||
background-color: var(--background-page);
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
|
||||
&.multiline {
|
||||
height: var(--input-height-tall);
|
||||
overflow-y: auto;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--border-normal-alt);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
:deep(.preview) {
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
padding: 2px 4px;
|
||||
color: var(--primary);
|
||||
font-size: 0;
|
||||
line-height: 1;
|
||||
vertical-align: -2px;
|
||||
background: var(--primary-alt);
|
||||
border-radius: var(--border-radius);
|
||||
user-select: text;
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
content: attr(data-preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useServerStore,
|
||||
useSettingsStore,
|
||||
useUserStore,
|
||||
useNotificationsStore,
|
||||
} from '@/stores';
|
||||
|
||||
type GenericStore = {
|
||||
@@ -37,6 +38,7 @@ export function useStores(
|
||||
useRelationsStore,
|
||||
usePermissionsStore,
|
||||
useInsightsStore,
|
||||
useNotificationsStore,
|
||||
]
|
||||
): GenericStore[] {
|
||||
return stores.map((useStore) => useStore()) as GenericStore[];
|
||||
|
||||
@@ -67,6 +67,8 @@ create_webhook: Create Webhook
|
||||
invite_users: Invite Users
|
||||
invite: Invite
|
||||
email_already_invited: Email "{email}" has already been invited
|
||||
subject: Subject
|
||||
inbox: Inbox
|
||||
emails: Emails
|
||||
connection_excellent: Excellent Connection
|
||||
connection_good: Good Connection
|
||||
@@ -433,6 +435,9 @@ user_count: 'No Users | One User | {count} Users'
|
||||
no_users_copy: There are no users in this role yet.
|
||||
webhooks_count: 'No Webhooks | One Webhook | {count} Webhooks'
|
||||
no_webhooks_copy: No webhooks have been configured yet. Get started by creating one below.
|
||||
no_notifications: No Notifications
|
||||
no_notifications_copy: You're all caught up!
|
||||
activity_log: Activity Log
|
||||
all_items: All Items
|
||||
any: Any
|
||||
csv: CSV
|
||||
@@ -858,6 +863,7 @@ directus_collection:
|
||||
directus_migrations: What version of the database you're using
|
||||
directus_panels: Individual panels within Insights dashboards
|
||||
directus_permissions: Access permissions for each role
|
||||
directus_notifications: Notifications sent to users
|
||||
directus_presets: Presets for collection defaults and bookmarks
|
||||
directus_relations: Relationship configuration and metadata
|
||||
directus_revisions: Data snapshots for all activity
|
||||
|
||||
@@ -98,10 +98,13 @@ os_totalmem: Память ОС
|
||||
archive: Архив
|
||||
archive_confirm: Вы уверены, что хотите архивировать этот элемент?
|
||||
archive_confirm_count: >-
|
||||
Элементы не выбраны | Вы уверены, что хотите архивировать этот элемент? | Вы уверены, что хотите архивировать эти {count} элементов?
|
||||
Элементы не выбраны | Вы уверены, что хотите архивировать этот элемент? | Вы уверены, что хотите архивировать эти
|
||||
{count} элементов?
|
||||
reset_system_permissions_to: 'Сбросить системные разрешения для:'
|
||||
reset_system_permissions_copy: Это перезапишет любые индивидуальные разрешения, которые вы могли задать системным коллекциям. Вы уверены?
|
||||
the_following_are_minimum_permissions: Ниже приведены разрешения, требуемые при включенном доступе к приложению. Их можно расширить, но не сократить.
|
||||
reset_system_permissions_copy:
|
||||
Это перезапишет любые индивидуальные разрешения, которые вы могли задать системным коллекциям. Вы уверены?
|
||||
the_following_are_minimum_permissions:
|
||||
Ниже приведены разрешения, требуемые при включенном доступе к приложению. Их можно расширить, но не сократить.
|
||||
app_access_minimum: Минимальный доступ приложения
|
||||
recommended_defaults: Рекомендуемые значения по умолчанию
|
||||
unarchive: Извлечь их архива
|
||||
@@ -234,7 +237,8 @@ this_will_auto_setup_fields_relations: Это автоматически нас
|
||||
click_here: Нажмите здесь
|
||||
to_manually_setup_translations: для ручной настройки переводов.
|
||||
click_to_manage_translated_fields: >-
|
||||
Полей перевода пока нет. Нажмите здесь, чтобы создать их. | Есть одно поле перевода. Нажмите здесь, чтобы управлять им. | Есть {count} полей перевода. Нажмите здесь, чтобы управлять ими.
|
||||
Полей перевода пока нет. Нажмите здесь, чтобы создать их. | Есть одно поле перевода. Нажмите здесь, чтобы управлять
|
||||
им. | Есть {count} полей перевода. Нажмите здесь, чтобы управлять ими.
|
||||
fields_group: Группа Полей
|
||||
no_collections_found: Нет найденных коллекций.
|
||||
new_data_alert: 'В вашей модели данных будет создано следующее:'
|
||||
@@ -491,7 +495,7 @@ account_created_successfully: Аккаунт Успешно Создан
|
||||
auto_fill: Автозаполнение
|
||||
corresponding_field: Соответствующее Поле
|
||||
errors:
|
||||
COLLECTION_NOT_FOUND: "Коллекция не существует"
|
||||
COLLECTION_NOT_FOUND: 'Коллекция не существует'
|
||||
FIELD_NOT_FOUND: Поле не найдено
|
||||
FORBIDDEN: Запрещено
|
||||
INVALID_CREDENTIALS: Неверное имя пользователя или пароль
|
||||
@@ -543,7 +547,8 @@ start_end_of_count_filtered_items: '{start}-{end} из {count} отфильтр
|
||||
one_item: '1 элемент'
|
||||
one_filtered_item: '1 отфильтрованный элемент'
|
||||
delete_collection_are_you_sure: >-
|
||||
Вы уверены, что хотите удалить эту коллекцию? Это действие приведет к удалению коллекции и всех ее элементов. Это действие необратимо.
|
||||
Вы уверены, что хотите удалить эту коллекцию? Это действие приведет к удалению коллекции и всех ее элементов. Это
|
||||
действие необратимо.
|
||||
collections_shown: Показано Коллекций
|
||||
visible_collections: Видимые Коллекции
|
||||
hidden_collections: Скрытые Коллекции
|
||||
@@ -579,7 +584,7 @@ operators:
|
||||
gte: Больше или равно
|
||||
in: Один из
|
||||
nin: Не один из
|
||||
null: "Null"
|
||||
null: 'Null'
|
||||
nnull: Не null
|
||||
contains: Содержит
|
||||
ncontains: Не содержит
|
||||
@@ -790,26 +795,33 @@ show_y_axis: Показать ось Y
|
||||
keep_editing: Продолжить Редактирование
|
||||
page_help_collections_overview: '**Обзор Коллекций** — Список всех коллекций, к которым у вас есть доступ.'
|
||||
page_help_collections_collection: >-
|
||||
**Просмотр Элементов** — Список всех элементов в {collection}, к которым у вас есть доступ. Настройте макет, фильтры и сортировку под себя, и даже сохраните закладки в разных конфигурациях для быстрого доступа.
|
||||
**Просмотр Элементов** — Список всех элементов в {collection}, к которым у вас есть доступ. Настройте макет, фильтры и
|
||||
сортировку под себя, и даже сохраните закладки в разных конфигурациях для быстрого доступа.
|
||||
page_help_collections_item: >-
|
||||
**Карточка Элемента** — Форма для просмотра и управления этим элементом. Эта панель также содержит полную историю изменений и встроенные комментарии.
|
||||
**Карточка Элемента** — Форма для просмотра и управления этим элементом. Эта панель также содержит полную историю
|
||||
изменений и встроенные комментарии.
|
||||
page_help_activity_collection: >-
|
||||
**Просмотр Активности** — Полный список всех действий пользователя и контента.
|
||||
page_help_docs_global: >-
|
||||
**Обзор Документации** — Документация, специально созданная к версии и схеме этого проекта.
|
||||
page_help_files_collection: >-
|
||||
**Библиотека Файлов** — Список всех файлов, загруженных в этот проект. Настройте макет, фильтры и сортировку под себя, и даже сохраните закладки в разных конфигурациях для быстрого доступа.
|
||||
**Библиотека Файлов** — Список всех файлов, загруженных в этот проект. Настройте макет, фильтры и сортировку под себя,
|
||||
и даже сохраните закладки в разных конфигурациях для быстрого доступа.
|
||||
page_help_files_item: >-
|
||||
**Карточка Файла** — Форма для управления метаданными файла, редактирования исходного файла и обновления настроек доступа.
|
||||
page_help_settings_project: "**Настройки Проекта** — Глобальные параметры конфигурации проекта."
|
||||
**Карточка Файла** — Форма для управления метаданными файла, редактирования исходного файла и обновления настроек
|
||||
доступа.
|
||||
page_help_settings_project: '**Настройки Проекта** — Глобальные параметры конфигурации проекта.'
|
||||
page_help_settings_datamodel_collections: >-
|
||||
**Модель Данных: Коллекции** — Список всех доступных коллекций. Включает видимые, скрытые и системные коллекции, а также неуправляемые таблицы базы данных, которые могут быть добавлены.
|
||||
**Модель Данных: Коллекции** — Список всех доступных коллекций. Включает видимые, скрытые и системные коллекции, а
|
||||
также неуправляемые таблицы базы данных, которые могут быть добавлены.
|
||||
page_help_settings_datamodel_fields: >-
|
||||
**Модель Данных: Коллекция** — Форма для управления этой коллекцией и ее полями.
|
||||
page_help_settings_roles_collection: '**Просмотр Ролей** — Список Администраторов, Публичных и собственных Ролей Пользователей.'
|
||||
page_help_settings_roles_item: "**Карточка Роли** — Управление разрешениями роли и другими настройками."
|
||||
page_help_settings_roles_collection:
|
||||
'**Просмотр Ролей** — Список Администраторов, Публичных и собственных Ролей Пользователей.'
|
||||
page_help_settings_roles_item: '**Карточка Роли** — Управление разрешениями роли и другими настройками.'
|
||||
page_help_settings_presets_collection: >-
|
||||
**Просмотр Пресетов** — Список всех пресетов проекта, включая: пользователей, ролей и глобальные закладки, а также представления по умолчанию.
|
||||
**Просмотр Пресетов** — Список всех пресетов проекта, включая: пользователей, ролей и глобальные закладки, а также
|
||||
представления по умолчанию.
|
||||
page_help_settings_presets_item: >-
|
||||
**Карточка Пресета** — Форма для управления пресетами закладок и коллекций по умолчанию.
|
||||
page_help_settings_webhooks_collection: '**Просмотр Веб-хуков** — Список всех веб-хуков в проекте.'
|
||||
@@ -826,7 +838,8 @@ all: Все
|
||||
none: Нет
|
||||
no_layout_collection_selected_yet: Макет/коллекция пока не выбраны
|
||||
batch_delete_confirm: >-
|
||||
Никаких элементов не выбрано | Вы уверены, что хотите удалить этот элемент? Это действие не может быть отменено. | Вы уверены, что хотите удалить эти элементы {count}? Это действие не может быть отменено.
|
||||
Никаких элементов не выбрано | Вы уверены, что хотите удалить этот элемент? Это действие не может быть отменено. | Вы
|
||||
уверены, что хотите удалить эти элементы {count}? Это действие не может быть отменено.
|
||||
cancel: Отмена
|
||||
no_upscale: Не масштабировать изображения
|
||||
collection: Коллекция
|
||||
@@ -947,7 +960,8 @@ fields:
|
||||
basemaps_style: Стиль Mapbox
|
||||
mapbox_key: Токен доступа Mapbox
|
||||
mapbox_placeholder: pk.eyJ1Ijo.....
|
||||
transforms_note: Имя метода Sharp и его аргументы. Дополнительную информацию см. в https://sharp.pixelplumbing.com/api-constructor.
|
||||
transforms_note:
|
||||
Имя метода Sharp и его аргументы. Дополнительную информацию см. в https://sharp.pixelplumbing.com/api-constructor.
|
||||
additional_transforms: Дополнительные преобразования
|
||||
project_name: Название проекта
|
||||
project_url: URL проекта
|
||||
@@ -1016,7 +1030,8 @@ field_options:
|
||||
fit_text: Поместить внутри
|
||||
outside_text: Поместить вне
|
||||
additional_transforms: Дополнительные преобразования
|
||||
transforms_note: Имя метода Sharp и его аргументы. Дополнительную информацию см. в https://sharp.pixelplumbing.com/api-constructor.
|
||||
transforms_note:
|
||||
Имя метода Sharp и его аргументы. Дополнительную информацию см. в https://sharp.pixelplumbing.com/api-constructor.
|
||||
mapbox_key: Токен доступа Mapbox
|
||||
mapbox_placeholder: pk.eyJ1Ijo.....
|
||||
basemaps: Основа карты
|
||||
@@ -1466,7 +1481,8 @@ displays:
|
||||
description: Показывать значения, относящиеся ко времени
|
||||
format: Формат
|
||||
format_note: >-
|
||||
Пользовательский формат принимает __[Date Field Symbol Table](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)__
|
||||
Пользовательский формат принимает __[Date Field Symbol
|
||||
Table](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table)__
|
||||
long: Длинный
|
||||
short: Короткий
|
||||
relative: Относительный
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getFieldsFromTemplate } from '@directus/shared/utils';
|
||||
import getFullcalendarLocale from '@/utils/get-fullcalendar-locale';
|
||||
import { renderPlainStringTemplate } from '@/utils/render-string-template';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { Field, Item, Filter } from '@directus/shared/types';
|
||||
import { Field, Item } from '@directus/shared/types';
|
||||
import { defineLayout } from '@directus/shared/utils';
|
||||
import { Calendar, CalendarOptions as FullCalendarOptions, EventInput } from '@fullcalendar/core';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
|
||||
27
app/src/modules/activity/index.ts
Normal file
27
app/src/modules/activity/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineModule } from '@directus/shared/utils';
|
||||
import ActivityCollection from './routes/collection.vue';
|
||||
import ActivityItem from './routes/item.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'activity',
|
||||
hidden: true,
|
||||
name: '$t:activity',
|
||||
icon: 'notifications',
|
||||
routes: [
|
||||
{
|
||||
name: 'activity-collection',
|
||||
path: '',
|
||||
component: ActivityCollection,
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
name: 'activity-item',
|
||||
path: ':primaryKey',
|
||||
components: {
|
||||
detail: ActivityItem,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<notifications-navigation v-model:filter="roleFilter" />
|
||||
<activity-navigation v-model:filter="roleFilter" />
|
||||
</template>
|
||||
|
||||
<component :is="`layout-${layout}`" v-bind="layoutState" class="layout">
|
||||
@@ -61,7 +61,7 @@
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed, ref } from 'vue';
|
||||
import NotificationsNavigation from '../components/navigation.vue';
|
||||
import ActivityNavigation from '../components/navigation.vue';
|
||||
import usePreset from '@/composables/use-preset';
|
||||
import { useLayout } from '@/composables/use-layout';
|
||||
import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detail';
|
||||
@@ -71,7 +71,7 @@ import { mergeFilters } from '@directus/shared/utils';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ActivityCollection',
|
||||
components: { NotificationsNavigation, LayoutSidebarDetail, SearchInput },
|
||||
components: { ActivityNavigation, LayoutSidebarDetail, SearchInput },
|
||||
props: {
|
||||
primaryKey: {
|
||||
type: String,
|
||||
@@ -39,7 +39,7 @@
|
||||
<v-icon name="launch" />
|
||||
</v-button>
|
||||
|
||||
<v-button v-tooltip.bottom="t('done')" to="/notifications" icon rounded>
|
||||
<v-button v-tooltip.bottom="t('done')" to="/activity" icon rounded>
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
@@ -132,7 +132,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function close() {
|
||||
router.push('/notifications');
|
||||
router.push('/activity');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -33,9 +33,9 @@ const checkForSystem: NavigationGuard = (to, from) => {
|
||||
|
||||
if (to.params.collection === 'directus_activity') {
|
||||
if (to.params.primaryKey) {
|
||||
return `/notifications/${to.params.primaryKey}`;
|
||||
return `/activity/${to.params.primaryKey}`;
|
||||
} else {
|
||||
return '/notifications';
|
||||
return '/activity';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { defineModule } from '@directus/shared/utils';
|
||||
import NotificationsCollection from './routes/collection.vue';
|
||||
import NotificationsItem from './routes/item.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'notifications',
|
||||
hidden: true,
|
||||
name: '$t:notifications',
|
||||
icon: 'notifications',
|
||||
routes: [
|
||||
{
|
||||
name: 'notifications-collection',
|
||||
path: '',
|
||||
component: NotificationsCollection,
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
name: 'notifications-item',
|
||||
path: ':primaryKey',
|
||||
components: {
|
||||
detail: NotificationsItem,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export const useAppStore = defineStore({
|
||||
id: 'appStore',
|
||||
state: () => ({
|
||||
sidebarOpen: false,
|
||||
notificationsDrawerOpen: false,
|
||||
fullScreen: false,
|
||||
hydrated: false,
|
||||
hydrating: false,
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
import { Notification, NotificationRaw } from '@/types';
|
||||
import { Snackbar, SnackbarRaw } from '@/types';
|
||||
import { reverse, sortBy } from 'lodash';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { defineStore } from 'pinia';
|
||||
import { Notification } from '@directus/shared/types';
|
||||
import { useUserStore } from '.';
|
||||
import api from '@/api';
|
||||
|
||||
export const useNotificationsStore = defineStore({
|
||||
id: 'notificationsStore',
|
||||
state: () => ({
|
||||
dialogs: [] as Notification[],
|
||||
queue: [] as Notification[],
|
||||
previous: [] as Notification[],
|
||||
dialogs: [] as Snackbar[],
|
||||
queue: [] as Snackbar[],
|
||||
previous: [] as Snackbar[],
|
||||
notifications: [] as Notification[],
|
||||
unread: 0,
|
||||
}),
|
||||
actions: {
|
||||
add(notification: NotificationRaw) {
|
||||
async hydrate() {
|
||||
await this.getUnreadCount();
|
||||
},
|
||||
async getUnreadCount() {
|
||||
const userStore = useUserStore();
|
||||
|
||||
const countResponse = await api.get('/notifications', {
|
||||
params: {
|
||||
filter: {
|
||||
_and: [
|
||||
{
|
||||
recipient: {
|
||||
_eq: userStore.currentUser!.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: {
|
||||
_eq: 'inbox',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
aggregate: {
|
||||
count: 'id',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.unread = countResponse.data.data[0].count.id;
|
||||
},
|
||||
add(notification: SnackbarRaw) {
|
||||
const id = nanoid();
|
||||
const timestamp = Date.now();
|
||||
|
||||
@@ -64,12 +99,12 @@ export const useNotificationsStore = defineStore({
|
||||
if (toBeRemoved.dialog === true) this.dialogs = this.dialogs.filter((n) => n.id !== id);
|
||||
else this.queue = this.queue.filter((n) => n.id !== id);
|
||||
},
|
||||
update(id: string, updates: Partial<Notification>) {
|
||||
update(id: string, updates: Partial<Snackbar>) {
|
||||
this.queue = this.queue.map(updateIfNeeded);
|
||||
this.dialogs = this.dialogs.map(updateIfNeeded);
|
||||
this.previous = this.queue.map(updateIfNeeded);
|
||||
|
||||
function updateIfNeeded(notification: Notification) {
|
||||
function updateIfNeeded(notification: Snackbar) {
|
||||
if (notification.id === id) {
|
||||
return {
|
||||
...notification,
|
||||
@@ -81,7 +116,7 @@ export const useNotificationsStore = defineStore({
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
lastFour(): Notification[] {
|
||||
lastFour(): Snackbar[] {
|
||||
const all = [...this.queue, ...this.previous.filter((l) => l.dialog !== true)];
|
||||
const chronologicalAll = reverse(sortBy(all, ['timestamp']));
|
||||
const newestFour = chronologicalAll.slice(0, 4);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface NotificationRaw {
|
||||
export interface SnackbarRaw {
|
||||
id?: string;
|
||||
persist?: boolean;
|
||||
title: string;
|
||||
@@ -12,7 +12,7 @@ export interface NotificationRaw {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export interface Notification extends NotificationRaw {
|
||||
export interface Snackbar extends SnackbarRaw {
|
||||
readonly id: string;
|
||||
readonly timestamp: number;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,38 @@
|
||||
<template>
|
||||
<v-textarea
|
||||
ref="textarea"
|
||||
v-model="newCommentContent"
|
||||
class="new-comment"
|
||||
:placeholder="t('leave_comment')"
|
||||
expand-on-focus
|
||||
>
|
||||
<template #append>
|
||||
<div class="input-container">
|
||||
<v-menu v-model="showMentionDropDown" attached>
|
||||
<template #activator>
|
||||
<v-template-input
|
||||
v-model="newCommentContent"
|
||||
capture-group="(@[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12})"
|
||||
multiline
|
||||
trigger-character="@"
|
||||
:items="userPreviews"
|
||||
@trigger="triggerSearch"
|
||||
@deactivate="showMentionDropDown = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item v-for="user in searchResult" id="suggestions" :key="user.id" clickable @click="insertUser(user)">
|
||||
<v-list-item-icon>
|
||||
<v-avatar x-small>
|
||||
<img v-if="user.avatar" :src="avatarSource(user.avatar)" />
|
||||
<v-icon v-else name="person_outline" />
|
||||
</v-avatar>
|
||||
</v-list-item-icon>
|
||||
|
||||
<v-list-item-content>
|
||||
{{ userName(user) }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<div class="buttons">
|
||||
<v-button v-if="existingComment" class="cancel" x-small secondary @click="$emit('cancel')">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button
|
||||
:disabled="!newCommentContent || newCommentContent.length === 0"
|
||||
:loading="saving"
|
||||
@@ -16,19 +42,27 @@
|
||||
>
|
||||
{{ t('submit') }}
|
||||
</v-button>
|
||||
</template>
|
||||
</v-textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, ref, PropType } from 'vue';
|
||||
import api from '@/api';
|
||||
import { defineComponent, ref, PropType, watch } from 'vue';
|
||||
import api, { addTokenToURL } from '@/api';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import { notify } from '@/utils/notify';
|
||||
import { userName } from '@/utils/user-name';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
import { throttle } from 'lodash';
|
||||
import axios, { CancelTokenSource } from 'axios';
|
||||
import { User } from '@directus/shared/types';
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import vTemplateInput from '@/components/v-template-input.vue';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
components: { vTemplateInput },
|
||||
props: {
|
||||
refresh: {
|
||||
type: Function as PropType<() => void>,
|
||||
@@ -42,31 +76,186 @@ export default defineComponent({
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
existingComment: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
previews: {
|
||||
type: Object as PropType<Record<string, string>>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ['cancel'],
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const textarea = ref<HTMLElement>();
|
||||
useShortcut('meta+enter', postComment, textarea);
|
||||
const newCommentContent = ref<string | null>(null);
|
||||
const saving = ref(false);
|
||||
|
||||
return { t, newCommentContent, postComment, saving, textarea };
|
||||
const newCommentContent = ref<string | null>(props.existingComment?.comment ?? null);
|
||||
|
||||
watch(
|
||||
() => props.existingComment,
|
||||
() => {
|
||||
if (props.existingComment?.comment) {
|
||||
newCommentContent.value = props.existingComment.comment;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const saving = ref(false);
|
||||
const showMentionDropDown = ref(false);
|
||||
|
||||
const searchResult = ref<User[]>([]);
|
||||
const userPreviews = ref<Record<string, string>>({});
|
||||
|
||||
watch(
|
||||
() => props.previews,
|
||||
() => {
|
||||
if (props.previews) {
|
||||
userPreviews.value = {
|
||||
...userPreviews.value,
|
||||
...props.previews,
|
||||
};
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
let triggerCaretPosition = 0;
|
||||
|
||||
let cancelToken: CancelTokenSource | null = null;
|
||||
|
||||
const loadUsers = throttle(async (name: string) => {
|
||||
if (cancelToken !== null) {
|
||||
cancelToken.cancel();
|
||||
}
|
||||
|
||||
cancelToken = axios.CancelToken.source();
|
||||
|
||||
const regex = /\s@[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}/gi;
|
||||
|
||||
let filter: Record<string, any> = {
|
||||
_or: [
|
||||
{
|
||||
first_name: {
|
||||
_starts_with: name,
|
||||
},
|
||||
},
|
||||
{
|
||||
last_name: {
|
||||
_starts_with: name,
|
||||
},
|
||||
},
|
||||
{
|
||||
email: {
|
||||
_starts_with: name,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (name.match(regex)) {
|
||||
filter = {
|
||||
id: {
|
||||
_in: name,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.get('/users', {
|
||||
params: {
|
||||
filter: name === '' || !name ? undefined : filter,
|
||||
fields: ['first_name', 'last_name', 'email', 'id', 'avatar'],
|
||||
},
|
||||
cancelToken: cancelToken.token,
|
||||
});
|
||||
|
||||
const newUsers = cloneDeep(userPreviews.value);
|
||||
|
||||
result.data.data.forEach((user: any) => {
|
||||
newUsers[user.id] = userName(user);
|
||||
});
|
||||
|
||||
userPreviews.value = newUsers;
|
||||
|
||||
searchResult.value = result.data.data;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return {
|
||||
t,
|
||||
newCommentContent,
|
||||
postComment,
|
||||
saving,
|
||||
textarea,
|
||||
showMentionDropDown,
|
||||
searchResult,
|
||||
avatarSource,
|
||||
userName,
|
||||
triggerSearch,
|
||||
insertUser,
|
||||
userPreviews,
|
||||
};
|
||||
|
||||
function insertUser(user: Record<string, any>) {
|
||||
const text = newCommentContent.value;
|
||||
if (!text) return;
|
||||
|
||||
let countBefore = triggerCaretPosition - 1;
|
||||
let countAfter = triggerCaretPosition;
|
||||
|
||||
if (text.charAt(countBefore) !== ' ') {
|
||||
while (countBefore >= 0 && text.charAt(countBefore) !== ' ') {
|
||||
countBefore--;
|
||||
}
|
||||
}
|
||||
|
||||
while (countAfter < text.length && text.charAt(countAfter) !== ' ') {
|
||||
countAfter++;
|
||||
}
|
||||
|
||||
const before = text.substring(0, countBefore);
|
||||
const after = text.substring(countAfter);
|
||||
|
||||
newCommentContent.value = before + ' @' + user.id + after;
|
||||
}
|
||||
|
||||
function triggerSearch({ searchQuery, caretPosition }: { searchQuery: string; caretPosition: number }) {
|
||||
triggerCaretPosition = caretPosition;
|
||||
|
||||
showMentionDropDown.value = true;
|
||||
loadUsers(searchQuery);
|
||||
}
|
||||
|
||||
function avatarSource(url: string) {
|
||||
if (url === null) return '';
|
||||
return addTokenToURL(getRootPath() + `assets/${url}?key=system-small-cover`);
|
||||
}
|
||||
|
||||
async function postComment() {
|
||||
if (newCommentContent.value === null || newCommentContent.value.length === 0) return;
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
await api.post(`/activity/comment`, {
|
||||
collection: props.collection,
|
||||
item: props.primaryKey,
|
||||
comment: newCommentContent.value,
|
||||
});
|
||||
if (props.existingComment) {
|
||||
await api.patch(`/activity/comment/${props.existingComment.id}`, {
|
||||
comment: newCommentContent.value,
|
||||
});
|
||||
} else {
|
||||
await api.post(`/activity/comment`, {
|
||||
collection: props.collection,
|
||||
item: props.primaryKey,
|
||||
comment: newCommentContent.value,
|
||||
});
|
||||
}
|
||||
|
||||
await props.refresh();
|
||||
props.refresh();
|
||||
|
||||
newCommentContent.value = null;
|
||||
newCommentContent.value = '';
|
||||
|
||||
notify({
|
||||
title: t('post_comment_success'),
|
||||
@@ -82,9 +271,32 @@ export default defineComponent({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.new-comment :deep(.expand-on-focus textarea) {
|
||||
<style scoped lang="scss">
|
||||
.input-container {
|
||||
position: relative;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.new-comment {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: var(--input-height);
|
||||
min-height: 100px;
|
||||
padding: 5px;
|
||||
overflow: scroll;
|
||||
white-space: pre;
|
||||
background-color: var(--background-input);
|
||||
border: var(--border-width) solid var(--border-normal);
|
||||
border-radius: var(--border-radius);
|
||||
transition: border-color var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
.new-comment:focus {
|
||||
position: relative;
|
||||
overflow: scroll;
|
||||
border-color: var(--primary);
|
||||
transition: margin-bottom var(--fast) var(--transition);
|
||||
}
|
||||
|
||||
@@ -127,9 +339,23 @@ export default defineComponent({
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.new-comment .post-comment {
|
||||
.buttons {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
|
||||
> * + * {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin-inline-start: 10px;
|
||||
}
|
||||
|
||||
#suggestions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,34 +2,17 @@
|
||||
<div class="comment-item">
|
||||
<comment-item-header :refresh="refresh" :activity="activity" @edit="editing = true" />
|
||||
|
||||
<v-textarea v-if="editing" ref="textarea" v-model="edits">
|
||||
<template #append>
|
||||
<div class="buttons">
|
||||
<v-button class="cancel" secondary x-small @click="cancelEditing">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<comment-input
|
||||
v-if="editing"
|
||||
:existing-comment="activity"
|
||||
:primary-key="primaryKey"
|
||||
:collection="collection"
|
||||
:refresh="refresh"
|
||||
:previews="userPreviews"
|
||||
@cancel="cancelEditing"
|
||||
/>
|
||||
|
||||
<v-button
|
||||
:loading="savingEdits"
|
||||
class="post-comment"
|
||||
x-small
|
||||
:disabled="edits === activity.comment"
|
||||
@click="saveEdits"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
</v-textarea>
|
||||
|
||||
<div v-else class="content">
|
||||
<span v-md="activity.comment" class="selectable" />
|
||||
|
||||
<!-- @TODO: Dynamically add element below if the comment overflows -->
|
||||
<!-- <div v-if="activity.id == 204" class="expand-text">
|
||||
<span>{{ t('click_to_expand') }}</span>
|
||||
</div> -->
|
||||
</div>
|
||||
<div v-else v-md="activity.display" class="content selectable" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,13 +21,14 @@ import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, PropType, ref, watch, ComponentPublicInstance } from 'vue';
|
||||
import { Activity } from './types';
|
||||
import CommentItemHeader from './comment-item-header.vue';
|
||||
import CommentInput from './comment-input.vue';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
|
||||
import api from '@/api';
|
||||
import { unexpectedError } from '@/utils/unexpected-error';
|
||||
|
||||
export default defineComponent({
|
||||
components: { CommentItemHeader },
|
||||
components: { CommentItemHeader, CommentInput },
|
||||
props: {
|
||||
activity: {
|
||||
type: Object as PropType<Activity>,
|
||||
@@ -54,6 +38,19 @@ export default defineComponent({
|
||||
type: Function as PropType<() => void>,
|
||||
required: true,
|
||||
},
|
||||
userPreviews: {
|
||||
type: Object,
|
||||
require: true,
|
||||
default: () => ({}),
|
||||
},
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
@@ -85,7 +82,8 @@ export default defineComponent({
|
||||
await api.patch(`/activity/comment/${props.activity.id}`, {
|
||||
comment: edits.value,
|
||||
});
|
||||
await props.refresh();
|
||||
|
||||
props.refresh();
|
||||
} catch (err: any) {
|
||||
unexpectedError(err);
|
||||
} finally {
|
||||
@@ -117,6 +115,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.comment-item .content {
|
||||
display: inline-block;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -145,6 +144,17 @@ export default defineComponent({
|
||||
border-top: 2px solid var(--border-normal);
|
||||
}
|
||||
|
||||
.comment-item .content :deep(mark) {
|
||||
display: inline-block;
|
||||
padding: 2px 4px;
|
||||
color: var(--primary);
|
||||
line-height: 1;
|
||||
background: var(--primary-alt);
|
||||
border-radius: var(--border-radius);
|
||||
user-select: text;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.comment-item .content :deep(:is(h1, h2, h3, h4, h5, h6)) {
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
@@ -201,6 +211,10 @@ export default defineComponent({
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
|
||||
@@ -12,7 +12,13 @@
|
||||
<v-divider>{{ group.dateFormatted }}</v-divider>
|
||||
|
||||
<template v-for="item in group.activity" :key="item.id">
|
||||
<comment-item :refresh="refresh" :activity="item" />
|
||||
<comment-item
|
||||
:refresh="refresh"
|
||||
:activity="item"
|
||||
:user-previews="userPreviews"
|
||||
:primary-key="primaryKey"
|
||||
:collection="collection"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</sidebar-detail>
|
||||
@@ -25,10 +31,11 @@ import { defineComponent, ref } from 'vue';
|
||||
import api from '@/api';
|
||||
import { Activity, ActivityByDate } from './types';
|
||||
import CommentInput from './comment-input.vue';
|
||||
import { groupBy, orderBy } from 'lodash';
|
||||
import { groupBy, orderBy, flatten } from 'lodash';
|
||||
import formatLocalized from '@/utils/localized-format';
|
||||
import { isToday, isYesterday, isThisYear } from 'date-fns';
|
||||
import CommentItem from './comment-item.vue';
|
||||
import { userName } from '@/utils/user-name';
|
||||
|
||||
export default defineComponent({
|
||||
components: { CommentInput, CommentItem },
|
||||
@@ -45,19 +52,21 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { activity, loading, error, refresh, count } = useActivity(props.collection, props.primaryKey);
|
||||
const { activity, loading, error, refresh, count, userPreviews } = useActivity(props.collection, props.primaryKey);
|
||||
|
||||
return { t, activity, loading, error, refresh, count };
|
||||
return { t, activity, loading, error, refresh, count, userPreviews };
|
||||
|
||||
function useActivity(collection: string, primaryKey: string | number) {
|
||||
const regex = /(\s@[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12})/gm;
|
||||
const activity = ref<ActivityByDate[] | null>(null);
|
||||
const count = ref(0);
|
||||
const error = ref(null);
|
||||
const loading = ref(false);
|
||||
const userPreviews = ref<Record<string, any>>({});
|
||||
|
||||
getActivity();
|
||||
|
||||
return { activity, error, loading, refresh, count };
|
||||
return { activity, error, loading, refresh, count, userPreviews };
|
||||
|
||||
async function getActivity() {
|
||||
error.value = null;
|
||||
@@ -68,7 +77,7 @@ export default defineComponent({
|
||||
params: {
|
||||
'filter[collection][_eq]': collection,
|
||||
'filter[item][_eq]': primaryKey,
|
||||
'filter[action][_in]': 'comment',
|
||||
'filter[action][_eq]': 'comment',
|
||||
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
|
||||
fields: [
|
||||
'id',
|
||||
@@ -87,7 +96,24 @@ export default defineComponent({
|
||||
|
||||
count.value = response.data.data.length;
|
||||
|
||||
const activityByDate = groupBy(response.data.data, (activity: Activity) => {
|
||||
userPreviews.value = await loadUserPreviews(response.data.data, regex);
|
||||
|
||||
const activityWithUsersInComments = response.data.data.map((comment: Record<string, any>) => {
|
||||
const matches = comment.comment.match(regex);
|
||||
|
||||
let newCommentText = comment.comment;
|
||||
|
||||
for (const match of matches ?? []) {
|
||||
newCommentText = newCommentText.replace(match, ` <mark>${userPreviews.value[match.substring(2)]}</mark>`);
|
||||
}
|
||||
|
||||
return {
|
||||
...comment,
|
||||
display: newCommentText,
|
||||
};
|
||||
});
|
||||
|
||||
const activityByDate = groupBy(activityWithUsersInComments, (activity: Activity) => {
|
||||
// activity's timestamp date is in iso-8601
|
||||
const date = new Date(new Date(activity.timestamp).toDateString());
|
||||
return date;
|
||||
@@ -127,6 +153,37 @@ export default defineComponent({
|
||||
await getActivity();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserPreviews(comments: Record<string, any>, regex: RegExp) {
|
||||
let userPreviews: any[] = [];
|
||||
|
||||
comments.forEach((comment: Record<string, any>) => {
|
||||
userPreviews.push(comment.comment.match(regex));
|
||||
});
|
||||
|
||||
const uniqIds: string[] = [...new Set(flatten(userPreviews))].filter((id) => {
|
||||
if (id) return id;
|
||||
});
|
||||
|
||||
if (uniqIds.length > 0) {
|
||||
const response = await api.get('/users', {
|
||||
params: {
|
||||
filter: { id: { _in: uniqIds.map((id) => id.substring(2)) } },
|
||||
fields: ['first_name', 'last_name', 'email', 'id'],
|
||||
},
|
||||
});
|
||||
|
||||
const userPreviews: Record<string, string> = {};
|
||||
|
||||
response.data.data.map((user: Record<string, any>) => {
|
||||
userPreviews[user.id] = userName(user);
|
||||
});
|
||||
|
||||
return userPreviews;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,51 +1,73 @@
|
||||
<template>
|
||||
<v-hover v-slot="{ hover }" class="module-bar-avatar">
|
||||
<v-dialog v-model="signOutActive" @esc="signOutActive = false">
|
||||
<template #activator="{ on }">
|
||||
<v-button
|
||||
v-tooltip.right="t('sign_out')"
|
||||
tile
|
||||
icon
|
||||
x-large
|
||||
:class="{ show: hover }"
|
||||
class="sign-out"
|
||||
@click="on"
|
||||
>
|
||||
<v-icon name="logout" />
|
||||
</v-button>
|
||||
</template>
|
||||
<div class="module-bar-avatar">
|
||||
<v-badge :value="unread" :disabled="unread == 0" class="notifications-badge">
|
||||
<v-button
|
||||
v-tooltip.right="t('notifications')"
|
||||
tile
|
||||
icon
|
||||
x-large
|
||||
class="notifications"
|
||||
@click="notificationsDrawerOpen = true"
|
||||
>
|
||||
<v-icon name="notifications" />
|
||||
</v-button>
|
||||
</v-badge>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>{{ t('sign_out_confirm') }}</v-card-title>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="signOutActive = !signOutActive">
|
||||
{{ t('cancel') }}
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-dialog v-model="signOutActive" @esc="signOutActive = false">
|
||||
<template #activator="{ on }">
|
||||
<v-button
|
||||
v-tooltip.right="t('sign_out')"
|
||||
tile
|
||||
icon
|
||||
x-large
|
||||
:class="{ show: hover }"
|
||||
class="sign-out"
|
||||
@click="on"
|
||||
>
|
||||
<v-icon name="logout" />
|
||||
</v-button>
|
||||
<v-button :to="signOutLink">{{ t('sign_out') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<router-link :to="userProfileLink">
|
||||
<v-avatar v-tooltip.right="userFullName" tile large :class="{ 'no-avatar': !avatarURL }">
|
||||
<img v-if="avatarURL" :src="avatarURL" :alt="userFullName" class="avatar-image" />
|
||||
<v-icon v-else name="account_circle" outline />
|
||||
</v-avatar>
|
||||
</router-link>
|
||||
</v-hover>
|
||||
<v-card>
|
||||
<v-card-title>{{ t('sign_out_confirm') }}</v-card-title>
|
||||
<v-card-actions>
|
||||
<v-button secondary @click="signOutActive = !signOutActive">
|
||||
{{ t('cancel') }}
|
||||
</v-button>
|
||||
<v-button :to="signOutLink">{{ t('sign_out') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<router-link :to="userProfileLink">
|
||||
<v-avatar v-tooltip.right="userFullName" tile large :class="{ 'no-avatar': !avatarURL }">
|
||||
<img v-if="avatarURL" :src="avatarURL" :alt="userFullName" class="avatar-image" />
|
||||
<v-icon v-else name="account_circle" outline />
|
||||
</v-avatar>
|
||||
</router-link>
|
||||
</v-hover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, computed, ref } from 'vue';
|
||||
import { useUserStore } from '@/stores/';
|
||||
import { useUserStore, useAppStore, useNotificationsStore } from '@/stores/';
|
||||
import { getRootPath } from '@/utils/get-root-path';
|
||||
import { addTokenToURL } from '@/api';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const notificationsStore = useNotificationsStore();
|
||||
|
||||
const { notificationsDrawerOpen } = storeToRefs(appStore);
|
||||
const { unread } = storeToRefs(notificationsStore);
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const signOutActive = ref(false);
|
||||
@@ -68,7 +90,7 @@ export default defineComponent({
|
||||
|
||||
const userFullName = userStore.fullName;
|
||||
|
||||
return { t, userFullName, avatarURL, userProfileLink, signOutActive, signOutLink };
|
||||
return { t, userFullName, avatarURL, userProfileLink, signOutActive, signOutLink, notificationsDrawerOpen, unread };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -82,6 +104,8 @@ export default defineComponent({
|
||||
--v-button-color-hover: var(--white);
|
||||
--v-avatar-color: var(--module-background);
|
||||
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
overflow: visible;
|
||||
|
||||
.avatar-image {
|
||||
@@ -117,6 +141,17 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
.notifications-badge {
|
||||
--v-badge-offset-x: 16px;
|
||||
--v-badge-offset-y: 16px;
|
||||
}
|
||||
|
||||
.notifications {
|
||||
--v-button-color: var(--module-icon);
|
||||
--v-button-background-color: var(--module-background);
|
||||
--v-button-background-color-hover: var(--module-background);
|
||||
}
|
||||
|
||||
.sign-out {
|
||||
--v-button-color: var(--module-icon);
|
||||
--v-button-background-color: var(--module-background);
|
||||
@@ -125,15 +160,16 @@ export default defineComponent({
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
transform: translateY(-100%);
|
||||
transition: transform var(--fast) var(--transition);
|
||||
|
||||
@media (min-width: 960px) {
|
||||
transform: translateY(0);
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
&.show {
|
||||
transform: translateY(-100%);
|
||||
transform: translateY(0%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<v-icon :name="modulePart.icon" outline />
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<module-bar-avatar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
210
app/src/views/private/components/notifications-drawer.vue
Normal file
210
app/src/views/private/components/notifications-drawer.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<v-drawer
|
||||
v-model="notificationsDrawerOpen"
|
||||
icon="notifications"
|
||||
:title="t('notifications')"
|
||||
@cancel="notificationsDrawerOpen = false"
|
||||
>
|
||||
<template #actions>
|
||||
<v-button
|
||||
v-tooltip.bottom="tab[0] === 'inbox' ? t('archive') : t('unarchive')"
|
||||
icon
|
||||
rounded
|
||||
:disabled="selection.length === 0"
|
||||
warning
|
||||
@click="toggleArchive"
|
||||
>
|
||||
<v-icon :name="tab[0] === 'inbox' ? 'archive' : 'move_to_inbox'" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #sidebar>
|
||||
<v-tabs v-model="tab" vertical>
|
||||
<v-tab value="inbox">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="inbox" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('inbox') }}</v-list-item-content>
|
||||
</v-tab>
|
||||
<v-tab value="archived">
|
||||
<v-list-item-icon>
|
||||
<v-icon name="archive" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>{{ t('archive') }}</v-list-item-content>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
</template>
|
||||
|
||||
<v-info v-if="!loading && notifications.length === 0" icon="notifications" :title="t('no_notifications')" center>
|
||||
{{ t('no_notifications_copy') }}
|
||||
</v-info>
|
||||
|
||||
<v-table
|
||||
v-else
|
||||
v-model="selection"
|
||||
v-model:headers="tableHeaders"
|
||||
show-select
|
||||
:loading="loading"
|
||||
:items="notifications"
|
||||
item-key="id"
|
||||
@click:row="onRowClick"
|
||||
></v-table>
|
||||
</v-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAppStore, useUserStore, useNotificationsStore } from '@/stores';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { Notification } from '@directus/shared/types';
|
||||
import api from '@/api';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import { Item } from '@directus/shared/types';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { localizedFormatDistance } from '@/utils/localized-format-distance';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const notificationsStore = useNotificationsStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const notifications = ref<Notification[]>([]);
|
||||
const page = ref(0);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const selection = ref([]);
|
||||
const tab = ref(['inbox']);
|
||||
|
||||
const { notificationsDrawerOpen } = storeToRefs(appStore);
|
||||
|
||||
const tableHeaders = ref<TableHeader[]>([
|
||||
{
|
||||
text: t('subject'),
|
||||
value: 'subject',
|
||||
sortable: false,
|
||||
width: 300,
|
||||
align: 'left',
|
||||
},
|
||||
{
|
||||
text: t('timestamp'),
|
||||
value: 'timestampDistance',
|
||||
sortable: false,
|
||||
width: 180,
|
||||
align: 'left',
|
||||
},
|
||||
]);
|
||||
|
||||
fetchNotifications();
|
||||
|
||||
watch([() => props.modelValue, tab], () => fetchNotifications());
|
||||
|
||||
return {
|
||||
tableHeaders,
|
||||
t,
|
||||
notificationsDrawerOpen,
|
||||
page,
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
selection,
|
||||
onRowClick,
|
||||
toggleArchive,
|
||||
tab,
|
||||
};
|
||||
|
||||
async function fetchNotifications() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.get('/notifications', {
|
||||
params: {
|
||||
filter: {
|
||||
_and: [
|
||||
{
|
||||
recipient: {
|
||||
_eq: userStore.currentUser!.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: {
|
||||
_eq: tab.value[0],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: ['id', 'subject', 'collection', 'item', 'timestamp'],
|
||||
sort: ['-timestamp'],
|
||||
},
|
||||
});
|
||||
|
||||
await notificationsStore.getUnreadCount();
|
||||
|
||||
const notificationsRaw = response.data.data as Notification[];
|
||||
|
||||
const notificationsWithRelative: (Notification & { timestampDistance: string })[] = [];
|
||||
|
||||
for (const notification of notificationsRaw) {
|
||||
notificationsWithRelative.push({
|
||||
...notification,
|
||||
timestampDistance: await localizedFormatDistance(parseISO(notification.timestamp), new Date(), {
|
||||
addSuffix: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
notifications.value = notificationsWithRelative;
|
||||
} catch (err: any) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
await api.patch('/notifications', {
|
||||
keys: selection.value.map(({ id }) => id),
|
||||
data: {
|
||||
status: tab.value[0] === 'inbox' ? 'archived' : 'inbox',
|
||||
},
|
||||
});
|
||||
|
||||
await fetchNotifications();
|
||||
|
||||
selection.value = [];
|
||||
}
|
||||
|
||||
function onRowClick({ item }: { item: Item; event: PointerEvent }) {
|
||||
router.push(`/content/${item.collection}/${item.item}`);
|
||||
notificationsDrawerOpen.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-table {
|
||||
display: contents;
|
||||
|
||||
& > :deep(table) {
|
||||
min-width: calc(100% - var(--content-padding)) !important;
|
||||
margin-left: var(--content-padding);
|
||||
|
||||
tr {
|
||||
margin-right: var(--content-padding);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<transition-expand tag="div">
|
||||
<div v-if="modelValue" class="inline">
|
||||
<div class="padding-box">
|
||||
<router-link class="link" to="/notifications" :class="{ 'has-items': lastFour.length > 0 }">
|
||||
<router-link class="link" to="/activity" :class="{ 'has-items': lastFour.length > 0 }">
|
||||
{{ t('show_all_activity') }}
|
||||
</router-link>
|
||||
<transition-group tag="div" name="notification" class="transition">
|
||||
@@ -17,10 +17,10 @@
|
||||
v-tooltip.left="t('notifications')"
|
||||
:active="modelValue"
|
||||
class="toggle"
|
||||
icon="notifications"
|
||||
icon="pending_actions"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
{{ t('notifications') }}
|
||||
{{ t('activity_log') }}
|
||||
</sidebar-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
<v-overlay class="nav-overlay" :active="navOpen" @click="navOpen = false" />
|
||||
<v-overlay class="sidebar-overlay" :active="sidebarOpen" @click="sidebarOpen = false" />
|
||||
|
||||
<notifications-drawer />
|
||||
<notifications-group v-if="notificationsPreviewActive === false" :dense="sidebarOpen === false" />
|
||||
<notification-dialogs />
|
||||
</div>
|
||||
@@ -72,9 +73,11 @@ import ProjectInfo from './components/project-info';
|
||||
import NotificationsGroup from './components/notifications-group/';
|
||||
import NotificationsPreview from './components/notifications-preview/';
|
||||
import NotificationDialogs from './components/notification-dialogs/';
|
||||
import NotificationsDrawer from './components/notifications-drawer.vue';
|
||||
import { useUserStore, useAppStore } from '@/stores';
|
||||
import { useRouter } from 'vue-router';
|
||||
import useTitle from '@/composables/use-title';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@@ -85,6 +88,7 @@ export default defineComponent({
|
||||
NotificationsGroup,
|
||||
NotificationsPreview,
|
||||
NotificationDialogs,
|
||||
NotificationsDrawer,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
@@ -114,7 +118,7 @@ export default defineComponent({
|
||||
|
||||
const notificationsPreviewActive = ref(false);
|
||||
|
||||
const { sidebarOpen, fullScreen } = toRefs(appStore);
|
||||
const { sidebarOpen, fullScreen } = storeToRefs(appStore);
|
||||
|
||||
const theme = computed(() => {
|
||||
return userStore.currentUser?.theme || 'auto';
|
||||
|
||||
Reference in New Issue
Block a user