Implemented frontend

This commit is contained in:
Mark van Renswoude 2021-08-18 11:16:01 +02:00
parent 773ee7db08
commit cc426c4c0f
19 changed files with 704 additions and 147 deletions

View File

@ -1,5 +1,5 @@
{ {
"name": "notificationlatch", "name": "notificationlatch-frontend",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
@ -1151,6 +1151,74 @@
"postcss": "^7.0.0" "postcss": "^7.0.0"
} }
}, },
"@intlify/core-base": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.7.tgz",
"integrity": "sha512-q1W2j81xbHyfKrNcca/CeJyf0Bcx4u9UDu05l7AaiJbqOseTme2o2I3wp1hDDCtmC7k7HgX0sAygyHNJH9swuQ==",
"requires": {
"@intlify/devtools-if": "9.1.7",
"@intlify/message-compiler": "9.1.7",
"@intlify/message-resolver": "9.1.7",
"@intlify/runtime": "9.1.7",
"@intlify/shared": "9.1.7",
"@intlify/vue-devtools": "9.1.7"
}
},
"@intlify/devtools-if": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/devtools-if/-/devtools-if-9.1.7.tgz",
"integrity": "sha512-/DcN5FUySSkQhDqx5y1RvxfuCXO3Ot/dUEIOs472qbM7Hyb2qif+eXCnwHBzlI4+wEfQVT6L0PiM1a7Er/ro9g==",
"requires": {
"@intlify/shared": "9.1.7"
}
},
"@intlify/message-compiler": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.1.7.tgz",
"integrity": "sha512-JZNkAhr3O7tnbdbRBcpYfqr/Ai26WTzX0K/lV8Y1KVdOIj/dGiamaffdWUdFiDXUnbJRNbPiOaKxy7Pwip3KxQ==",
"requires": {
"@intlify/message-resolver": "9.1.7",
"@intlify/shared": "9.1.7",
"source-map": "0.6.1"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
"@intlify/message-resolver": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/message-resolver/-/message-resolver-9.1.7.tgz",
"integrity": "sha512-WTK+OaXJYjyquLGhuCyDvU2WHkG+kXzXeHagmVFHn+s118Jf2143zzkLLUrapP5CtZ/csuyjmYg7b3xQRQAmvw=="
},
"@intlify/runtime": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/runtime/-/runtime-9.1.7.tgz",
"integrity": "sha512-QURPSlzhOVnRwS2XMGpCDsDkP42kfVBh94aAORxh/gVGzdgJip2vagrIFij/J69aEqdB476WJkMhVjP8VSHmiA==",
"requires": {
"@intlify/message-compiler": "9.1.7",
"@intlify/message-resolver": "9.1.7",
"@intlify/shared": "9.1.7"
}
},
"@intlify/shared": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.1.7.tgz",
"integrity": "sha512-zt0zlUdalumvT9AjQNxPXA36UgOndUyvBMplh8uRZU0fhWHAwhnJTcf0NaG9Qvr8I1n3HPSs96+kLb/YdwTavQ=="
},
"@intlify/vue-devtools": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/@intlify/vue-devtools/-/vue-devtools-9.1.7.tgz",
"integrity": "sha512-DI5Wc0aOiohtBUGUkKAcryCWbbuaO4/PK4Pa/LaNCsFNxbtgR5qkIDmhBv9xVPYGTUhySXxaDDAMvOpBjhPJjw==",
"requires": {
"@intlify/message-resolver": "9.1.7",
"@intlify/runtime": "9.1.7",
"@intlify/shared": "9.1.7"
}
},
"@mrmlnc/readdir-enhanced": { "@mrmlnc/readdir-enhanced": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz",
@ -1279,6 +1347,12 @@
"integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==", "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==",
"dev": true "dev": true
}, },
"@types/luxon": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz",
"integrity": "sha512-L7iL3FitRSeuz8fbeLtql7qU6inHVtwEDWI1+vBXgyp0J2tmxOD7TgMBiEQjII/Y/TPcwrKasXb1BPuiCXRgxg==",
"dev": true
},
"@types/mime": { "@types/mime": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
@ -2465,6 +2539,14 @@
"integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==", "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
"dev": true "dev": true
}, },
"axios": {
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"requires": {
"follow-redirects": "^1.10.0"
}
},
"babel-code-frame": { "babel-code-frame": {
"version": "6.26.0", "version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
@ -5369,8 +5451,7 @@
"follow-redirects": { "follow-redirects": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz",
"integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==", "integrity": "sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg=="
"dev": true
}, },
"for-in": { "for-in": {
"version": "1.0.2", "version": "1.0.2",
@ -7074,6 +7155,11 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"luxon": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.0.2.tgz",
"integrity": "sha512-ZRioYLCgRHrtTORaZX1mx+jtxKtKuI5ZDvHNAmqpUzGqSrR+tL4FVLn/CUGMA3h0+AKD1MAxGI5GnCqR5txNqg=="
},
"magic-string": { "magic-string": {
"version": "0.25.7", "version": "0.25.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz",
@ -11087,6 +11173,17 @@
"integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==", "integrity": "sha512-BXq3jwIagosjgNVae6tkHzzIk6a8MHFtzAdwhnV5VlvPTFxDCvIttgSiHWjdGoTJvXtmRu5HacExfdarRcFhog==",
"dev": true "dev": true
}, },
"vue-i18n": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.1.7.tgz",
"integrity": "sha512-ujuuDanoHqtEd4GejWrbG/fXE9nrP51ElsEGxp0WBHfv+/ki0/wyUqkO+4fLikki2obGtXdviTPH0VNpas5K6g==",
"requires": {
"@intlify/core-base": "9.1.7",
"@intlify/shared": "9.1.7",
"@intlify/vue-devtools": "9.1.7",
"@vue/devtools-api": "^6.0.0-beta.7"
}
},
"vue-loader": { "vue-loader": {
"version": "15.9.8", "version": "15.9.8",
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.9.8.tgz", "resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-15.9.8.tgz",

View File

@ -7,11 +7,15 @@
"build": "vue-cli-service build" "build": "vue-cli-service build"
}, },
"dependencies": { "dependencies": {
"axios": "^0.21.1",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"luxon": "^2.0.2",
"vue": "^3.0.0", "vue": "^3.0.0",
"vue-i18n": "^9.1.7",
"vue-router": "^4.0.0-0" "vue-router": "^4.0.0-0"
}, },
"devDependencies": { "devDependencies": {
"@types/luxon": "^2.0.0",
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0", "@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0", "@vue/cli-plugin-typescript": "~4.5.0",

View File

@ -5,11 +5,11 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title> <title>Notifications</title>
</head> </head>
<body> <body>
<noscript> <noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but this site doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->

View File

@ -1,30 +1,14 @@
<template> <template>
<div id="nav"> <router-view />
<router-link to="/">Home</router-link> |
<router-link to="/about">About</router-link>
</div>
<router-view/>
</template> </template>
<style lang="scss"> <style lang="scss">
#app { body
font-family: Avenir, Helvetica, Arial, sans-serif; {
background: #001133;
color: #cccccc;
font-family: 'Verdana', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
}
#nav {
padding: 30px;
a {
font-weight: bold;
color: #2c3e50;
&.router-link-exact-active {
color: #42b983;
}
}
} }
</style> </style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -1,61 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-router" target="_blank" rel="noopener">router</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-typescript" target="_blank" rel="noopener">typescript</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'HelloWorld',
props: {
msg: String,
},
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

24
frontend/src/locale/en.ts Normal file
View File

@ -0,0 +1,24 @@
export default {
notification: {
loading: 'Loading, please wait...',
tokenInvalid: 'Invalid token',
listHeader: 'Disabled notifications',
latchTime: 'Sent on {latchTime}',
latched: 'This notification will not be sent again until it is re-enabled.',
enableNotification: 'Enable',
reminders: 'If enabled, a reminder will be sent every {interval}. Use the buttons below to disable or enable reminders. This will apply only to this notification.',
remindersDisabled: 'No reminders',
remindersEnabled: 'Send reminders'
},
duration: {
glue: ', ',
lastGlue: ' and ',
days: '{count} day | {count} days',
hours: '{count} hour | {count} hours',
minutes: '{count} minute | {count} minutes',
seconds: '{count} second | {count} seconds'
}
}

View File

@ -0,0 +1,7 @@
import en from './en';
import nl from './nl';
export default {
en,
nl
};

24
frontend/src/locale/nl.ts Normal file
View File

@ -0,0 +1,24 @@
export default {
notification: {
loading: 'Bezig met laden, een ogenblik geduld a.u.b....',
tokenInvalid: 'Ongeldig token',
listHeader: 'Uitgeschakelde meldingen',
latchTime: 'Verzonden op {latchTime}',
latched: 'Deze notificatie wordt niet meer verzonden totdat deze weer wordt ingeschakeld.',
enableNotification: 'Inschakelen',
reminders: 'Indien ingeschakeld wordt een herinnering elke {interval} gestuurd. Gebruik de knoppen hieronder om herinneringen uit of in te schakelen. Dit geldt alleen voor deze notificatie.',
remindersDisabled: 'Geen herinneringen',
remindersEnabled: 'Herinneringen sturen'
},
duration: {
glue: ', ',
lastGlue: ' en ',
days: '{count} dag | {count} dagen',
hours: '{count} uur | {count} uren',
minutes: '{count} minuut | {count} minuten',
seconds: '{count} seconde | {count} seconden'
}
}

View File

@ -1,5 +1,17 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import App from './App.vue' import { createI18n } from 'vue-i18n';
import router from './router' import App from './App.vue';
import router from './router';
import messages from './locale';
createApp(App).use(router).mount('#app') const i18n = createI18n({
locale: navigator.language.substr(0, 2),
fallbackLocale: 'en',
messages
});
createApp(App)
.use(router)
.use(i18n)
.mount('#app')

View File

@ -0,0 +1,28 @@
import { Duration } from 'luxon';
export interface ILatchedNotifications
{
reminders: INotificationReminders;
notifications: Array<INotification>;
}
export interface INotificationReminders
{
enabled: boolean;
interval: Duration;
}
export interface INotification
{
token: string;
id: string;
title: string;
latched: boolean;
latchTime: number;
resetTime?: number;
reminders: boolean;
remindTime?: number;
}

View File

@ -1,25 +1,25 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router' import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
import Home from '../views/Home.vue' import Default from '../views/Default.vue';
import Notification from '../views/Notification.vue';
const routes: Array<RouteRecordRaw> = [ const routes: Array<RouteRecordRaw> = [
{ {
path: '/', path: '/',
name: 'Home', name: 'Default',
component: Home component: Default
}, },
{ {
path: '/about', path: '/n/:token',
name: 'About', name: 'Notification',
// route level code-splitting component: Notification,
// this generates a separate chunk (about.[hash].js) for this route props: true
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
} }
] ];
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes routes
}) });
export default router export default router;

View File

@ -1,5 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<p class="default">
Nothing to see here, move along.
</p>
</template>

View File

@ -1,18 +0,0 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js + TypeScript App"/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import HelloWorld from '@/components/HelloWorld.vue'; // @ is an alias to /src
export default defineComponent({
name: 'Home',
components: {
HelloWorld,
},
});
</script>

View File

@ -0,0 +1,342 @@
<template>
<div>
<p class="loading" v-if="tokenValid === null">
{{ $t('notification.loading') }}
</p>
<p class="invalid" v-if="tokenValid === false">
{{ $t('notification.tokenInvalid') }}
</p>
<div class="notifications" v-if="tokenValid === true">
<div class="header">{{ $t('notification.listHeader') }}</div>
<div class="list">
<div class="notification" v-for="notification in orderedNotifications" :key="notification.id">
<div class="title">{{ notification.title }}</div>
<div class="content">
<div class="info">
<p class="latchTime">{{ $t('notification.latchTime', { latchTime: formatDateTime(notification.latchTime) }) }}</p>
<p class="latched">{{ $t('notification.latched') }}</p>
</div>
<div class="actions">
<a href="#" @click.prevent="enableNotification(notification)" class="enable" :class="{ active: !notification.enabling }">{{ $t('notification.enableNotification') }}</a>
</div>
<div class="reminders" v-if="reminders.enabled">
<p class="interval">{{ $t('notification.reminders', { interval: formattedReminderInterval })}}</p>
<div class="actions">
<a href="#" @click.prevent="enableReminders(notification)" class="reminders-enabled" :class="{ active: !notification.updatingReminders && notification.reminders }">{{ $t('notification.remindersEnabled') }}</a>
<a href="#" @click.prevent="disableReminders(notification)" class="reminders-disabled" :class="{ active: !notification.updatingReminders && !notification.reminders }">{{ $t('notification.remindersDisabled') }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import axios from 'axios';
import { DateTime, Duration } from 'luxon';
import { ILatchedNotifications, INotificationReminders, INotification } from '../model/notifications';
interface INotificationViewModel extends INotification
{
enabling: boolean;
updatingReminders: boolean;
}
export default defineComponent({
props: [
'token'
],
data()
{
return {
tokenValid: null as null | boolean,
reminders: null as null | INotificationReminders,
notifications: null as null | Array<INotificationViewModel>
};
},
mounted()
{
this.refreshToken();
},
watch: {
token()
{
this.refreshToken();
}
},
computed: {
formattedReminderInterval(): string
{
if (this.reminders === null || !this.reminders.enabled)
return '';
const duration = Duration.fromObject(this.reminders.interval);
const interval = duration.shiftTo('days', 'hours', 'minutes', 'seconds');
const units = [];
if (interval.days)
units.push(this.$tc('duration.days', interval.days));
if (interval.hours)
units.push(this.$tc('duration.hours', interval.hours));
if (interval.minutes)
units.push(this.$tc('duration.minutes', interval.minutes));
if (interval.seconds)
units.push(this.$tc('duration.seconds', interval.seconds));
if (units.length == 0)
return '<error>';
if (units.length == 1)
return units[0];
const lastUnit = units.pop();
return units.join(this.$t('duration.glue')) + this.$t('duration.lastGlue') + lastUnit;
},
orderedNotifications(): Array<INotificationViewModel>
{
if (this.notifications === null)
return [];
return [...this.notifications].sort((a, b) =>
{
if (a.token === this.token)
return -1;
if (b.token === this.token)
return 1;
return a.title.localeCompare(b.title);
});
}
},
methods: {
async refreshToken()
{
try
{
const response = await axios.get<ILatchedNotifications>('/api/notification/latched', {
headers: {
'Authorization': 'Bearer ' + this.token
}
});
this.reminders = response.data.reminders;
this.notifications = response.data.notifications.map(notification =>
{
return {
token: notification.token,
id: notification.id,
title: notification.title,
latched: notification.latched,
latchTime: notification.latchTime,
resetTime: notification.resetTime,
reminders: notification.reminders,
remindTime: notification.remindTime,
enabling: false,
updatingReminders: false
} as INotificationViewModel;
});
this.tokenValid = true;
}
catch
{
this.tokenValid = false;
}
},
async enableNotification(notification: INotificationViewModel)
{
if (this.notifications == null || notification.enabling)
return;
notification.enabling = true;
try
{
await axios.post('/api/notification/' + notification.token + '/reset');
this.notifications = this.notifications.filter((n: INotification) => n !== notification);
}
finally
{
notification.enabling = false;
}
},
async enableReminders(notification: INotificationViewModel)
{
if (notification.updatingReminders)
return;
notification.updatingReminders = true;
try
{
await axios.post('/api/notification/' + notification.token + '/reminders/enable');
notification.reminders = true;
}
finally
{
notification.updatingReminders = false;
}
},
async disableReminders(notification: INotificationViewModel)
{
if (notification.updatingReminders)
return;
notification.updatingReminders = true;
try
{
await axios.post('/api/notification/' + notification.token + '/reminders/disable');
notification.reminders = false;
}
finally
{
notification.updatingReminders = false;
}
},
formatDateTime(unixTimestamp: number)
{
return DateTime.fromSeconds(unixTimestamp).toLocaleString(DateTime.DATETIME_FULL);
}
}
});
</script>
<style lang="scss">
.notifications
{
margin: 1rem;
.header
{
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 2rem;
text-align: center;
}
.list
{
color: black;
.notification
{
background: #fcfcfc;
border: solid 1px #2b5074;
border-radius: 5px;
margin-bottom: 3rem;
.title
{
background: #d4e1ee;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
font-weight: bold;
padding: .5rem;
}
.content
{
margin: .5rem;
}
.info
{
font-size: .75rem;
.latchTime
{
color: gray;
margin-top: 0;
}
}
.actions
{
margin-top: .25rem;
margin-bottom: 1rem;
}
.enable,
.reminders-disabled,
.reminders-enabled
{
display: inline-block;
padding: .5em;
text-decoration: none;
border-radius: 5px;
margin-right: .5rem;
color: gray;
background: white;
border: solid 1px darkgray;
cursor: pointer;
}
.active
{
cursor: default;
&.enable,
&.reminders-enabled
{
color: white;
background: #248f24;
border: solid 1px green;
}
&.enable
{
cursor: pointer;
}
&.reminders-disabled
{
color: white;
background: darkred;
border: solid 1px red;
}
}
.reminders
{
.interval
{
font-size: .75rem;
}
}
}
}
}
</style>

View File

@ -22,6 +22,9 @@ class NotificationFacade
return true; return true;
}); });
this.publicUrl = config.publicUrl;
this.reminders = config.reminders;
} }
@ -41,7 +44,7 @@ class NotificationFacade
priority: priority, priority: priority,
sound: parsedSubject.sound, sound: parsedSubject.sound,
timestamp: this.dateTimeProvider.unixTimestamp(), timestamp: this.dateTimeProvider.unixTimestamp(),
url: 'https://www.hierhaduwurlkunnenstaan.nl/' url: new URL('/#/n/' + token, this.publicUrl).href
}); });
} }
@ -53,6 +56,28 @@ class NotificationFacade
getLatchedNotifications(token)
{
const notifications = this.notificationRepository.getLatchedNotifications(token);
return notifications !== null
? {
reminders: {
enabled: this.reminders.enabled,
interval: this.reminders.interval
},
notifications: notifications
}
: null;
}
async setReminders(token, enabled)
{
await this.notificationRepository.setReminders(token, enabled);
}
_sendNotification(notification) _sendNotification(notification)
{ {
this.contacts.forEach(contact => this.contacts.forEach(contact =>

View File

@ -107,6 +107,57 @@ class NotificationRepository
} }
getLatchedNotifications(token)
{
this._checkInitialized();
if (!this.notifications.hasOwnProperty(token))
return null;
return Object.keys(this.notifications)
.filter(notificationToken => this.notifications[notificationToken].latched)
.map(notificationToken => {
const notification = this.notifications[notificationToken];
return {
token: notificationToken,
id: notification.id,
title: notification.title,
latched: notification.latched,
latchTime: notification.latchTime,
resetTime: notification.resetTime,
reminders: notification.reminders,
remindTime: notification.remindTime
};
});
}
async setReminders(token, enabled)
{
this._checkInitialized();
if (!this.notifications.hasOwnProperty(token))
{
this.logger.info(`Notification token '${token}' does not exist, unable to change reminders setting`);
return;
}
const notification = this.notifications[token];
if (notification.reminders == enabled)
{
this.logger.info(`Notification with id '${notification.id}' and token '${token}' reminders is already ${enabled}`);
return;
}
this.logger.info(`Setting reminders for notification with id '${notification.id}' and token '${token}' to ${enabled}`);
notification.reminders = enabled;
await this._flush();
}
_getNotificationToken(id) _getNotificationToken(id)
{ {
const hasher = crypto.createHmac("sha256", this.salt); const hasher = crypto.createHmac("sha256", this.salt);

View File

@ -1,6 +1,6 @@
class ApiRoutes class ApiRoutes
{ {
static create = container => new this(container.logger, container.NotificationFacade, container.Config); static create = container => new this(container.Logger, container.NotificationFacade, container.Config);
constructor(logger, notificationFacade, config) constructor(logger, notificationFacade, config)
@ -15,8 +15,11 @@ class ApiRoutes
{ {
const router = express.Router(); const router = express.Router();
router.post('/notification', (req, res) => this._wrapAsyncHandler(req, res, this._handlePostNotification.bind(this))); router.post('/notification', this._wrapAsyncHandler(this._handlePostNotification));
router.post('/notification/reset', (req, res) => this._wrapAsyncHandler(req, res, this._handleResetNotification.bind(this))); router.get('/notification/latched', this._wrapAsyncHandler(this._handleLatchedNotifications));
router.post('/notification/:token/reset', this._wrapAsyncHandler(this._handleResetNotification));
router.post('/notification/:token/reminders/enable', this._wrapAsyncHandler(this._handleEnableReminders));
router.post('/notification/:token/reminders/disable', this._wrapAsyncHandler(this._handleDisableReminders));
return router; return router;
} }
@ -45,23 +48,57 @@ class ApiRoutes
async _handleResetNotification(req, res) async _handleResetNotification(req, res)
{ {
if (!req.body || !req.body.token) await this.notificationFacade.resetNotification(req.params.token);
{
this._logRequestWarning(req, 'Missing token');
res.sendStatus(400);
return;
}
await this.notificationFacade.resetNotification(req.body.token);
res.sendStatus(200); res.sendStatus(200);
} }
async _wrapAsyncHandler(req, res, handler) async _handleEnableReminders(req, res)
{
await this.notificationFacade.setReminders(req.params.token, true);
res.sendStatus(200);
}
async _handleDisableReminders(req, res)
{
await this.notificationFacade.setReminders(req.params.token, false);
res.sendStatus(200);
}
async _handleLatchedNotifications(req, res)
{
if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer '))
{
this._logRequestWarning(req, 'Missing or invalid authorization header');
res.sendStatus(401);
return;
}
const token = req.headers.authorization.substr(7);
const notifications = this.notificationFacade.getLatchedNotifications(token);
if (notifications == null)
{
this._logRequestWarning(req, `Invalid token: ${token}`);
res.sendStatus(401);
return;
}
res.send(JSON.stringify(notifications));
}
_wrapAsyncHandler(handler)
{
const boundHandler = handler.bind(this);
return async (req, res) =>
{ {
try try
{ {
await handler(req, res); await boundHandler(req, res);
} }
catch (err) catch (err)
{ {
@ -70,6 +107,7 @@ class ApiRoutes
res.sendStatus(500); res.sendStatus(500);
} }
} }
}
_logRequestWarning(req, message) _logRequestWarning(req, message)