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: b4458c19f 34131d06e
Author: 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: c93b833d2 af2a6dd7f
Author: 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 commit 428e5d4ea9.

* 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:
Jay Cammarano
2021-11-24 16:11:26 -05:00
committed by GitHub
parent f19a549a1b
commit 25375cc481
49 changed files with 3162 additions and 2531 deletions

View File

@@ -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);

View File

@@ -206,6 +206,7 @@ body {
--content-padding: 16px;
--content-padding-bottom: 32px;
position: relative;
flex-grow: 1;
overflow: auto;

View File

@@ -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,

View 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>)`),
`&nbsp;<mark class="preview" data-preview="${
props.items[match.substring(props.triggerCharacter.length)]
}" contenteditable="false">${match}</mark>&nbsp;`
);
}
}
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>

View File

@@ -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[];

View File

@@ -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

View File

@@ -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: Относительный

View File

@@ -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';

View 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,
},
},
],
},
],
});

View File

@@ -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,

View File

@@ -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');
}
},
});

View File

@@ -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';
}
}

View File

@@ -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,
},
},
],
},
],
});

View File

@@ -4,6 +4,7 @@ export const useAppStore = defineStore({
id: 'appStore',
state: () => ({
sidebarOpen: false,
notificationsDrawerOpen: false,
fullScreen: false,
hydrated: false,
hydrating: false,

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 {

View File

@@ -23,6 +23,7 @@
<v-icon :name="modulePart.icon" outline />
</v-button>
</div>
<module-bar-avatar />
</div>
</template>

View 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>

View File

@@ -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>

View File

@@ -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';