Add standalone NotificationsView
This commit is contained in:
@@ -34,6 +34,7 @@ import EmailSettingsView from './views/EmailSettingsView.vue';
|
|||||||
import EmailEventlogView from './views/EmailEventlogView.vue';
|
import EmailEventlogView from './views/EmailEventlogView.vue';
|
||||||
import EventlogView from './views/EventlogView.vue';
|
import EventlogView from './views/EventlogView.vue';
|
||||||
import NetworkView from './views/NetworkView.vue';
|
import NetworkView from './views/NetworkView.vue';
|
||||||
|
import NotificationsView from './views/NotificationsView.vue';
|
||||||
import ProfileView from './views/ProfileView.vue';
|
import ProfileView from './views/ProfileView.vue';
|
||||||
import ServicesView from './views/ServicesView.vue';
|
import ServicesView from './views/ServicesView.vue';
|
||||||
import SystemSettingsView from './views/SystemSettingsView.vue';
|
import SystemSettingsView from './views/SystemSettingsView.vue';
|
||||||
@@ -64,6 +65,7 @@ const VIEWS = Object.freeze({
|
|||||||
EMAIL_EVENTLOG: '#/email-eventlog',
|
EMAIL_EVENTLOG: '#/email-eventlog',
|
||||||
SERVER: '#/server',
|
SERVER: '#/server',
|
||||||
NETWORK: '#/network',
|
NETWORK: '#/network',
|
||||||
|
NOTIFICATIONS: '#/notifications',
|
||||||
PROFILE: '#/profile',
|
PROFILE: '#/profile',
|
||||||
SERVICES: '#/services',
|
SERVICES: '#/services',
|
||||||
SYSTEM_SETTINGS: '#/system-settings',
|
SYSTEM_SETTINGS: '#/system-settings',
|
||||||
@@ -319,6 +321,8 @@ function onHashChange() {
|
|||||||
view.value = VIEWS.EMAIL_EVENTLOG;
|
view.value = VIEWS.EMAIL_EVENTLOG;
|
||||||
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
|
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
|
||||||
view.value = VIEWS.SERVER;
|
view.value = VIEWS.SERVER;
|
||||||
|
} else if (v === VIEWS.NOTIFICATIONS && profile.value.isAtLeastAdmin) {
|
||||||
|
view.value = VIEWS.NOTIFICATIONS;
|
||||||
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
|
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
|
||||||
view.value = VIEWS.NETWORK;
|
view.value = VIEWS.NETWORK;
|
||||||
} else if (v === VIEWS.PROFILE) {
|
} else if (v === VIEWS.PROFILE) {
|
||||||
@@ -481,6 +485,7 @@ onUnmounted(() => {
|
|||||||
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
|
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
|
||||||
<ServerView v-else-if="view === VIEWS.SERVER" />
|
<ServerView v-else-if="view === VIEWS.SERVER" />
|
||||||
<NetworkView v-else-if="view === VIEWS.NETWORK" />
|
<NetworkView v-else-if="view === VIEWS.NETWORK" />
|
||||||
|
<NotificationsView v-else-if="view === VIEWS.NOTIFICATIONS" />
|
||||||
<ProfileView v-else-if="view === VIEWS.PROFILE" />
|
<ProfileView v-else-if="view === VIEWS.PROFILE" />
|
||||||
<ServicesView v-else-if="view === VIEWS.SERVICES" />
|
<ServicesView v-else-if="view === VIEWS.SERVICES" />
|
||||||
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
|
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ const t = i18n.t;
|
|||||||
|
|
||||||
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { eachLimit } from 'async';
|
import { Menu, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||||
import { Menu, Button, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
|
||||||
import { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
|
|
||||||
import NotificationsModel from '../models/NotificationsModel.js';
|
import NotificationsModel from '../models/NotificationsModel.js';
|
||||||
import ServicesModel from '../models/ServicesModel.js';
|
import ServicesModel from '../models/ServicesModel.js';
|
||||||
import ProfileModel from '../models/ProfileModel.js';
|
import ProfileModel from '../models/ProfileModel.js';
|
||||||
@@ -28,47 +26,10 @@ const servicesModel = ServicesModel.create();
|
|||||||
const profileModel = ProfileModel.create();
|
const profileModel = ProfileModel.create();
|
||||||
|
|
||||||
const notificationModel = NotificationsModel.create();
|
const notificationModel = NotificationsModel.create();
|
||||||
const notificationButton = useTemplateRef('notificationButton');
|
|
||||||
const notificationPopover = useTemplateRef('notificationPopover');
|
|
||||||
const notifications = ref([]);
|
const notifications = ref([]);
|
||||||
const notificationsAllBusy = ref(false);
|
|
||||||
|
|
||||||
function onOpenNotifications(popover, event, elem) {
|
|
||||||
popover.open(event, elem);
|
|
||||||
|
|
||||||
// close after 2 seconds if there is nothing to show
|
|
||||||
if (notifications.value.length === 0) setTimeout(popover.close, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMarkNotificationRead(notification) {
|
|
||||||
notification.busy = true;
|
|
||||||
const [error] = await notificationModel.update(notification.id, true);
|
|
||||||
if (error) return console.error(error);
|
|
||||||
|
|
||||||
await refresh();
|
|
||||||
|
|
||||||
// close after 2 seconds if there is nothing to show
|
|
||||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onMarkAllNotificationRead() {
|
|
||||||
notificationsAllBusy.value = true;
|
|
||||||
|
|
||||||
await eachLimit(notifications.value, 5, async (notification) => {
|
|
||||||
notification.busy = true;
|
|
||||||
const [error] = await notificationModel.update(notification.id, true);
|
|
||||||
if (error) return console.error(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
await refresh();
|
|
||||||
|
|
||||||
notificationsAllBusy.value = false;
|
|
||||||
|
|
||||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const [error, result] = await notificationModel.list();
|
const [error, result] = await notificationModel.list(false);
|
||||||
if (error) return console.error(error);
|
if (error) return console.error(error);
|
||||||
|
|
||||||
result.forEach(n => {
|
result.forEach(n => {
|
||||||
@@ -150,30 +111,6 @@ onUnmounted(() => {
|
|||||||
<InputDialog ref="inputDialog"/>
|
<InputDialog ref="inputDialog"/>
|
||||||
<Menu ref="avatarMenu" :model="avatarActions" />
|
<Menu ref="avatarMenu" :model="avatarActions" />
|
||||||
|
|
||||||
<Popover ref="notificationPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
|
||||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
|
||||||
<div v-if="notifications.length" style="overflow: auto; margin-bottom: 10px">
|
|
||||||
<div class="notification-item" v-for="notification in notifications" :key="notification.id">
|
|
||||||
<div class="notification-item-title" @click="notification.isCollapsed = !notification.isCollapsed">
|
|
||||||
<div>
|
|
||||||
{{ notification.title }}<br/>
|
|
||||||
<span class="notification-item-date" v-tooltip="prettyLongDate(notification.creationTime)">{{ prettyDate(notification.creationTime) }}</span>
|
|
||||||
</div>
|
|
||||||
<Button plain small tool :loading="notification.busy" :disabled="notification.busy" class="notification-read-button" @click.stop="onMarkNotificationRead(notification)">{{ $t('notifications.dismissTooltip') }}</Button>
|
|
||||||
</div>
|
|
||||||
<div class="notification-item-body" v-if="!notification.isCollapsed">
|
|
||||||
<pre v-if="notification.messageJson" style="cursor: auto">{{ JSON.stringify(notification.messageJson, null, 4) }}</pre>
|
|
||||||
<div v-else style="cursor: auto; overflow: auto;" v-html="marked.parse(notification.message)"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button v-if="notifications.length" @click="onMarkAllNotificationRead()" :loading="notificationsAllBusy" :disabled="notificationsAllBusy">{{ $t('notifications.markAllAsRead') }}</Button>
|
|
||||||
<div v-if="notifications.length === 0" class="notification-item-empty-placeholder">
|
|
||||||
{{ $t('notifications.allCaughtUp') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||||
<h1 class="help-title">{{ $t('support.help.title') }}</h1>
|
<h1 class="help-title">{{ $t('support.help.title') }}</h1>
|
||||||
@@ -199,7 +136,7 @@ onUnmounted(() => {
|
|||||||
<!-- Warnings if subscription is expired or unpaid -->
|
<!-- Warnings if subscription is expired or unpaid -->
|
||||||
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
|
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
|
||||||
|
|
||||||
<div class="headerbar-action" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
|
<a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="/#/notifications"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</a>
|
||||||
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||||
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
|
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
|
||||||
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||||
@@ -249,40 +186,6 @@ onUnmounted(() => {
|
|||||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
border-bottom: 1px solid var(--pankow-input-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item-title {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item-date {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-read-button {
|
|
||||||
visibility: hidden;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-item:hover .notification-read-button {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
.notification-item .notification-read-button {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscription-expired {
|
.subscription-expired {
|
||||||
background-color: var(--pankow-color-danger);
|
background-color: var(--pankow-color-danger);
|
||||||
color: white;
|
color: white;
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ function create() {
|
|||||||
const accessToken = localStorage.token;
|
const accessToken = localStorage.token;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async list(acknowledged = false) {
|
async list(acknowledged = null) {
|
||||||
|
const query = {
|
||||||
|
access_token: accessToken,
|
||||||
|
per_page: 1000
|
||||||
|
};
|
||||||
|
|
||||||
|
if (acknowledged !== null) query.acknowledged = !!acknowledged;
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 1000 });
|
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, query);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return [e];
|
return [e];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup>
|
||||||
|
|
||||||
|
import { marked } from 'marked';
|
||||||
|
import { eachLimit } from 'async';
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { Button } from '@cloudron/pankow';
|
||||||
|
import { prettyDate } from '@cloudron/pankow/utils';
|
||||||
|
import NotificationsModel from '../models/NotificationsModel.js';
|
||||||
|
|
||||||
|
const notificationsModel = NotificationsModel.create();
|
||||||
|
|
||||||
|
const busy = ref(true);
|
||||||
|
const notifications = ref([]);
|
||||||
|
const notificationsAllBusy = ref(false);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const [error, result] = await notificationsModel.list();
|
||||||
|
if (error) return console.error(error);
|
||||||
|
|
||||||
|
notifications.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleActive(notification) {
|
||||||
|
notification.active = !notification.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMarkNotificationRead(notification) {
|
||||||
|
notification.busy = true;
|
||||||
|
const [error] = await notificationsModel.update(notification.id, true);
|
||||||
|
if (error) return console.error(error);
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMarkNotificationUnread(notification) {
|
||||||
|
notification.busy = true;
|
||||||
|
const [error] = await notificationsModel.update(notification.id, false);
|
||||||
|
if (error) return console.error(error);
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMarkAllNotificationRead() {
|
||||||
|
notificationsAllBusy.value = true;
|
||||||
|
|
||||||
|
await eachLimit(notifications.value.filter(n => !n.acknowledged), 5, async (notification) => {
|
||||||
|
notification.busy = true;
|
||||||
|
const [error] = await notificationsModel.update(notification.id, true);
|
||||||
|
if (error) return console.error(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
notificationsAllBusy.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refresh();
|
||||||
|
|
||||||
|
busy.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="content">
|
||||||
|
<!-- <h1 class="view-header">{{ $t('notifications.title') }}</h1> -->
|
||||||
|
<h1 class="notification-list-header">
|
||||||
|
Notifications
|
||||||
|
<Button secondary @click="onMarkAllNotificationRead()" :loading="notificationsAllBusy" :disabled="notificationsAllBusy">{{ $t('notifications.markAllAsRead') }}</Button>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="notification-list">
|
||||||
|
<TransitionGroup name="fade">
|
||||||
|
<div v-for="notification in notifications" :key="notification.id" class="notification-item" :class="{ new: !notification.acknowledged, active: notification.active }">
|
||||||
|
<div class="notification-item-title" @click="onToggleActive(notification)">
|
||||||
|
<div>
|
||||||
|
{{ notification.title }}
|
||||||
|
<div class="notification-item-date">{{ prettyDate(notification.creationTime) }}</div>
|
||||||
|
</div>
|
||||||
|
<Button v-if="notification.acknowledged" plain secondary tool :loading="notification.busy && !notificationsAllBusy" :disabled="notification.busy" @click.stop="onMarkNotificationUnread(notification)">Unread</Button>
|
||||||
|
<Button v-else plain primary tool :loading="notification.busy && !notificationsAllBusy" :disabled="notification.busy" @click.stop="onMarkNotificationRead(notification)">Dismiss</Button>
|
||||||
|
</div>
|
||||||
|
<div class="notification-item-message">
|
||||||
|
<div style="cursor: auto; overflow: auto;" v-html="marked.parse(notification.message)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.notification-list-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.new {
|
||||||
|
background-color: var(--navbar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item:hover {
|
||||||
|
background-color: var(--pankow-color-background-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.active {
|
||||||
|
z-index: 500;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item-date {
|
||||||
|
font-size: 12px;
|
||||||
|
padding-top: 5px;
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item-message {
|
||||||
|
display: none;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-item.active .notification-item-message {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-move,
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user