Implemented notification center. Closes #290
This commit is contained in:
parent
731614961e
commit
e8a52509b3
6 changed files with 222 additions and 7 deletions
134
ui/components/NotifyDropdown.vue
Normal file
134
ui/components/NotifyDropdown.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<template>
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a class="navbar-link">
|
||||||
|
<span class="icon"><i class="fas fa-bell" /></span>
|
||||||
|
<span class="tag is-underlined ml-2">{{ store.unreadCount }}</span>
|
||||||
|
</a>
|
||||||
|
<div class="navbar-dropdown is-right" style="width: 400px;">
|
||||||
|
<template v-if="store.notifications.length > 0">
|
||||||
|
<div class="px-3 py-2 is-flex is-justify-content-space-between is-align-items-center">
|
||||||
|
<span class="has-text-grey">
|
||||||
|
<span class="is-underlined">{{ store.notifications.length }}</span> Total
|
||||||
|
</span>
|
||||||
|
<div class="field is-grouped">
|
||||||
|
|
||||||
|
<div class="control" v-if="store.unreadCount > 0">
|
||||||
|
<button class="button is-small is-light mr-1" @click="store.markAllRead">
|
||||||
|
<span class="icon"><i class="fas fa-check" /></span>
|
||||||
|
<span>Mark all read</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-small is-danger is-light" @click="store.clear">
|
||||||
|
<span class="icon"><i class="fas fa-trash" /></span>
|
||||||
|
<span>Clear all</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="navbar-divider">
|
||||||
|
</template>
|
||||||
|
<div class="notification-list">
|
||||||
|
<div v-for="n in store.notifications" :key="n.id" class="navbar-item is-flex is-align-items-start"
|
||||||
|
:class="['notification-item', 'notification-' + n.level]">
|
||||||
|
<div class="is-flex-grow-1">
|
||||||
|
<p class="is-size-7 mb-1 notification-message" :class="{ expanded: expandedId === n.id }"
|
||||||
|
@click="toggleExpand(n.id)">
|
||||||
|
{{ n.message }}
|
||||||
|
</p>
|
||||||
|
<p class="is-size-7 has-text-grey">
|
||||||
|
<span :date-datetime="n.created" v-tooltip="moment(n.created).format('YYYY-M-DD H:mm Z')"
|
||||||
|
v-rtime="n.created" />
|
||||||
|
- <NuxtLink @click="copy_text(n.id, n.message)">
|
||||||
|
<span v-if="copiedId === n.id" class="has-text-success">Copied!</span>
|
||||||
|
<span v-else>Copy</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 is-flex is-flex-direction-column is-justify-content-center">
|
||||||
|
<div class="field is-grouped">
|
||||||
|
<div class="control" v-if="!n.seen">
|
||||||
|
<button class="button is-small is-light" @click="store.markRead(n.id)">
|
||||||
|
<span class="icon"><i class="fas fa-check" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button class="button is-danger is-small" @click="store.remove(n.id)">
|
||||||
|
<span class="icon"><i class="fas fa-trash" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="store.notifications.length < 1" class="navbar-item is-flex is-align-items-start">
|
||||||
|
<div class="is-flex-grow-1 has-text-centered has-text-grey">
|
||||||
|
<p class="is-size-7">No notifications</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import moment from 'moment'
|
||||||
|
const store = useNotificationStore()
|
||||||
|
const copiedId = ref(null)
|
||||||
|
const expandedId = ref(null)
|
||||||
|
|
||||||
|
const toggleExpand = id => expandedId.value = expandedId.value === id ? null : id
|
||||||
|
|
||||||
|
const copy_text = (id, text) => {
|
||||||
|
copiedId.value = id
|
||||||
|
copyText(text, false, false)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (copiedId.value === id) {
|
||||||
|
copiedId.value = null
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-item {
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-info {
|
||||||
|
border-color: var(--bulma-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-success {
|
||||||
|
border-color: var(--bulma-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-warning {
|
||||||
|
border-color: var(--bulma-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-error {
|
||||||
|
border-color: var(--bulma-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-message.expanded {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,12 +1,23 @@
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import { POSITION, useToast } from "vue-toastification"
|
import { POSITION, useToast } from "vue-toastification"
|
||||||
|
|
||||||
type notificationType = 'info' | 'success' | 'warning' | 'error'
|
export type notificationType = 'info' | 'success' | 'warning' | 'error'
|
||||||
type notificationOptions = {
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
message: string
|
||||||
|
level: notificationType
|
||||||
|
seen: boolean
|
||||||
|
created: Date
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface notificationOptions {
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
force?: boolean,
|
force?: boolean,
|
||||||
|
store?: boolean,
|
||||||
closeOnClick?: boolean,
|
closeOnClick?: boolean,
|
||||||
position?: POSITION
|
position?: POSITION
|
||||||
|
onClick?: (closeToast: Function) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowToast = useStorage<boolean>('allow_toasts', true)
|
const allowToast = useStorage<boolean>('allow_toasts', true)
|
||||||
|
|
@ -15,11 +26,24 @@ const toastDismissOnClick = useStorage<boolean>('toast_dismiss_on_click', true)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
|
function notify(type: notificationType, message: string, opts?: notificationOptions): void {
|
||||||
let force = opts?.force || false;
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
let id: string = ''
|
||||||
|
const force = opts?.force || false;
|
||||||
|
const store = opts?.store || true;
|
||||||
|
|
||||||
|
if (store && notificationStore) {
|
||||||
|
id = notificationStore.add(type, message, false)
|
||||||
|
}
|
||||||
|
|
||||||
if (false === allowToast.value && false === force) {
|
if (false === allowToast.value && false === force) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (false === document.hasFocus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!opts) {
|
if (!opts) {
|
||||||
opts = {}
|
opts = {}
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +54,14 @@ function notify(type: notificationType, message: string, opts?: notificationOpti
|
||||||
|
|
||||||
opts.closeOnClick = toastDismissOnClick.value
|
opts.closeOnClick = toastDismissOnClick.value
|
||||||
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
opts.position = toastPosition.value ?? POSITION.TOP_RIGHT
|
||||||
|
opts.onClick = (closeToast: Function) => {
|
||||||
|
if (opts?.closeOnClick !== false) {
|
||||||
|
closeToast()
|
||||||
|
}
|
||||||
|
if (notificationStore) {
|
||||||
|
notificationStore.markRead(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'info':
|
case 'info':
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotifyDropdown />
|
||||||
|
|
||||||
<div class="navbar-item is-hidden-mobile">
|
<div class="navbar-item is-hidden-mobile">
|
||||||
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
|
<button class="button is-dark has-tooltip-bottom mr-4" v-tooltip.bottom="'WebUI Settings'"
|
||||||
@click="show_settings = !show_settings">
|
@click="show_settings = !show_settings">
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ const isLoading = ref(true)
|
||||||
const initialLoad = ref(true)
|
const initialLoad = ref(true)
|
||||||
const addInProgress = ref(false)
|
const addInProgress = ref(false)
|
||||||
const display_style = useStorage("tasks_display_style", "cards")
|
const display_style = useStorage("tasks_display_style", "cards")
|
||||||
|
const remove_keys = ['in_progress']
|
||||||
|
|
||||||
watch(() => config.app.basic_mode, async () => {
|
watch(() => config.app.basic_mode, async () => {
|
||||||
if (!config.app.basic_mode) {
|
if (!config.app.basic_mode) {
|
||||||
|
|
@ -295,7 +296,7 @@ const updateTasks = async items => {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(items),
|
body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))),
|
||||||
})
|
})
|
||||||
|
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
|
|
@ -338,7 +339,6 @@ const deleteItem = async item => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateItem = async ({ reference, task }) => {
|
const updateItem = async ({ reference, task }) => {
|
||||||
task = cleanObject(task, ['in_progress'])
|
|
||||||
if (reference) {
|
if (reference) {
|
||||||
// -- find the task index.
|
// -- find the task index.
|
||||||
const index = tasks.value.findIndex((t) => t?.id === reference)
|
const index = tasks.value.findIndex((t) => t?.id === reference)
|
||||||
|
|
|
||||||
47
ui/stores/NotificationStore.ts
Normal file
47
ui/stores/NotificationStore.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import type { Notification, notificationType } from '~/composables/useNotification'
|
||||||
|
|
||||||
|
export const useNotificationStore = defineStore('notifications', () => {
|
||||||
|
const notifications = useStorage<Notification[]>('notifications', [])
|
||||||
|
const unreadCount = computed<number>(() => notifications.value.filter(n => !n.seen).length)
|
||||||
|
|
||||||
|
const add = (level: notificationType, message: string, seen: boolean = false): string => {
|
||||||
|
const id = Array.from(
|
||||||
|
window.crypto.getRandomValues(new Uint8Array(14 / 2)),
|
||||||
|
(dec: any) => dec.toString(16).padStart(2, "0")
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
notifications.value.unshift({
|
||||||
|
id: id,
|
||||||
|
message,
|
||||||
|
level,
|
||||||
|
seen: seen,
|
||||||
|
created: new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (notifications.value.length > 99) {
|
||||||
|
notifications.value.length = 99
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => notifications.value = []
|
||||||
|
const markAllRead = () => notifications.value.forEach(n => n.seen = true)
|
||||||
|
|
||||||
|
const markRead = (id: string) => {
|
||||||
|
console.log(`Marking notification ${id} as read`)
|
||||||
|
const n = notifications.value.find(n => n.id === id)
|
||||||
|
if (!n) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n.seen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (id: string): Notification | undefined => notifications.value.find(n => n.id === id)
|
||||||
|
|
||||||
|
const remove = (id: string) => notifications.value = notifications.value.filter(n => n.id !== id)
|
||||||
|
|
||||||
|
return { notifications, unreadCount, add, get, markAllRead, clear, markRead, remove }
|
||||||
|
})
|
||||||
|
|
@ -125,7 +125,7 @@ const r = (text, context = {}) => {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyText = (str, notify = true) => {
|
const copyText = (str, notify = true, store = false) => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(str).then(() => {
|
navigator.clipboard.writeText(str).then(() => {
|
||||||
if (notify) {
|
if (notify) {
|
||||||
|
|
@ -148,7 +148,7 @@ const copyText = (str, notify = true) => {
|
||||||
document.body.removeChild(el)
|
document.body.removeChild(el)
|
||||||
|
|
||||||
if (notify) {
|
if (notify) {
|
||||||
toast.success('Text copied to clipboard.')
|
toast.success('Text copied to clipboard.', { store: store })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue