diff --git a/src/components/home/topNavigation.vue b/src/components/home/topNavigation.vue
index 5c1f4e13..c0c17ce3 100644
--- a/src/components/home/topNavigation.vue
+++ b/src/components/home/topNavigation.vue
@@ -37,6 +37,7 @@
+
@@ -86,10 +87,12 @@ import Rights from '@/models/rights.json'
import Update from '@/components/home/update'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown'
import Dropdown from '@/components/misc/dropdown'
+import Notifications from '@/components/notifications/notifications'
export default {
name: 'topNavigation',
components: {
+ Notifications,
Dropdown,
ListSettingsDropdown,
Update,
diff --git a/src/components/notifications/notifications.vue b/src/components/notifications/notifications.vue
new file mode 100644
index 00000000..d1fcca47
--- /dev/null
+++ b/src/components/notifications/notifications.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
Notifications
+
+
+ You don't have any notifications. Have a nice day!
+
+ Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen.
+
+
+
+
+
+
+
+
diff --git a/src/components/tasks/partials/heading.vue b/src/components/tasks/partials/heading.vue
index ac10fd51..04b748a5 100644
--- a/src/components/tasks/partials/heading.vue
+++ b/src/components/tasks/partials/heading.vue
@@ -1,10 +1,7 @@
-
- #{{ task.index }}
-
-
- {{ task.identifier }}
+
+ {{ task.getTextIdentifier() }}
Done
{
+ if (date && !date.startsWith('0001')) {
+ return new Date(date)
+ }
+ return null
+}
diff --git a/src/models/notification.js b/src/models/notification.js
new file mode 100644
index 00000000..779cb7fb
--- /dev/null
+++ b/src/models/notification.js
@@ -0,0 +1,84 @@
+import AbstractModel from '@/models/abstractModel'
+import {parseDateOrNull} from '@/helpers/parseDateOrNull'
+import UserModel from '@/models/user'
+import TaskModel from '@/models/task'
+import TaskCommentModel from '@/models/taskComment'
+import ListModel from '@/models/list'
+import TeamModel from '@/models/team'
+import names from './notificationNames.json'
+
+export default class NotificationModel extends AbstractModel {
+ constructor(data) {
+ super(data)
+
+ switch (this.name) {
+ case names.TASK_COMMENT:
+ this.notification.doer = new UserModel(this.notification.doer)
+ this.notification.task = new TaskModel(this.notification.task)
+ this.notification.comment = new TaskCommentModel(this.notification.comment)
+ break
+ case names.TASK_ASSIGNED:
+ this.notification.doer = new UserModel(this.notification.doer)
+ this.notification.task = new TaskModel(this.notification.task)
+ this.notification.assignee = new UserModel(this.notification.assignee)
+ break
+ case names.TASK_DELETED:
+ this.notification.doer = new UserModel(this.notification.doer)
+ this.notification.task = new TaskModel(this.notification.task)
+ break
+ case names.LIST_CREATED:
+ this.notification.doer = new UserModel(this.notification.doer)
+ this.notification.list = new ListModel(this.notification.list)
+ break
+ case names.TEAM_MEMBER_ADDED:
+ this.notification.doer = new UserModel(this.notification.doer)
+ this.notification.member = new UserModel(this.notification.member)
+ this.notification.team = new TeamModel(this.notification.team)
+ break
+ }
+
+ this.created = new Date(this.created)
+ this.readAt = parseDateOrNull(this.readAt)
+ }
+
+ defaults() {
+ return {
+ id: 0,
+ name: '',
+ notification: null,
+ read: false,
+ readAt: null,
+ }
+ }
+
+ toText(user = null) {
+ let who = ''
+
+ switch (this.name) {
+ case names.TASK_COMMENT:
+ return `commented on ${this.notification.task.getTextIdentifier()}`
+ case names.TASK_ASSIGNED:
+ who = `${this.notification.assignee.getDisplayName()}`
+
+ if (user !== null && user.id === this.notification.assignee.id) {
+ who = 'you'
+ }
+
+ return `assigned ${who} to ${this.notification.task.getTextIdentifier()}`
+ case names.TASK_DELETED:
+ return `deleted ${this.notification.task.getTextIdentifier()}`
+ case names.LIST_CREATED:
+ return `created ${this.notification.list.title}`
+ case names.TEAM_MEMBER_ADDED:
+ who = `${this.notification.member.getDisplayName()}`
+
+ if (user !== null && user.id === this.notification.memeber.id) {
+ who = 'you'
+ }
+
+ return `added ${who} to the ${this.notification.team.title} team`
+ }
+
+ return ''
+ }
+}
diff --git a/src/models/notificationNames.json b/src/models/notificationNames.json
new file mode 100644
index 00000000..62260fe3
--- /dev/null
+++ b/src/models/notificationNames.json
@@ -0,0 +1,7 @@
+{
+ "TASK_COMMENT": "task.comment",
+ "TASK_ASSIGNED": "task.assigned",
+ "TASK_DELETED": "task.deleted",
+ "LIST_CREATED": "list.created",
+ "TEAM_MEMBER_ADDED": "team.member.added"
+}
\ No newline at end of file
diff --git a/src/models/task.js b/src/models/task.js
index d6385d50..9ef7fab6 100644
--- a/src/models/task.js
+++ b/src/models/task.js
@@ -3,13 +3,7 @@ import UserModel from './user'
import LabelModel from './label'
import AttachmentModel from './attachment'
import SubscriptionModel from '@/models/subscription'
-
-const parseDate = date => {
- if (date && !date.startsWith('0001')) {
- return new Date(date)
- }
- return null
-}
+import {parseDateOrNull} from '@/helpers/parseDateOrNull'
export default class TaskModel extends AbstractModel {
@@ -22,10 +16,10 @@ export default class TaskModel extends AbstractModel {
this.listId = Number(this.listId)
// Make date objects from timestamps
- this.dueDate = parseDate(this.dueDate)
- this.startDate = parseDate(this.startDate)
- this.endDate = parseDate(this.endDate)
- this.doneAt = parseDate(this.doneAt)
+ this.dueDate = parseDateOrNull(this.dueDate)
+ this.startDate = parseDateOrNull(this.startDate)
+ this.endDate = parseDateOrNull(this.endDate)
+ this.doneAt = parseDateOrNull(this.doneAt)
// Cancel all scheduled notifications for this task to be sure to only have available notifications
this.cancelScheduledNotifications()
@@ -76,7 +70,7 @@ export default class TaskModel extends AbstractModel {
this.identifier = ''
}
- if(typeof this.subscription !== 'undefined' && this.subscription !== null) {
+ if (typeof this.subscription !== 'undefined' && this.subscription !== null) {
this.subscription = new SubscriptionModel(this.subscription)
}
@@ -119,6 +113,14 @@ export default class TaskModel extends AbstractModel {
}
}
+ getTextIdentifier() {
+ if(this.identifier === '') {
+ return `#${this.index}`
+ }
+
+ return this.identifier
+ }
+
/////////////////
// Helper functions
///////////////
diff --git a/src/services/notification.js b/src/services/notification.js
new file mode 100644
index 00000000..ff21251f
--- /dev/null
+++ b/src/services/notification.js
@@ -0,0 +1,22 @@
+import AbstractService from '@/services/abstractService'
+import {formatISO} from 'date-fns'
+import NotificationModel from '@/models/notification'
+
+export default class NotificationService extends AbstractService {
+ constructor() {
+ super({
+ getAll: '/notifications',
+ update: '/notifications/{id}',
+ })
+ }
+
+ modelFactory(data) {
+ return new NotificationModel(data)
+ }
+
+ beforeUpdate(model) {
+ model.created = formatISO(new Date(model.created))
+ model.readAt = formatISO(new Date(model.readAt))
+ return model
+ }
+}
\ No newline at end of file
diff --git a/src/styles/components/_all.scss b/src/styles/components/_all.scss
index 345cf776..4a1720de 100644
--- a/src/styles/components/_all.scss
+++ b/src/styles/components/_all.scss
@@ -22,3 +22,4 @@
@import 'keyboard-shortcuts';
@import 'api-config';
@import 'datepicker';
+@import 'notifications';
diff --git a/src/styles/components/notifications.scss b/src/styles/components/notifications.scss
new file mode 100644
index 00000000..b3adbccb
--- /dev/null
+++ b/src/styles/components/notifications.scss
@@ -0,0 +1,110 @@
+.notifications {
+ width: 50px;
+
+ .trigger {
+ cursor: pointer;
+ color: $grey-400;
+ padding: 1rem;
+ font-size: 1.25rem;
+ position: relative;
+
+ .unread-indicator {
+ position: absolute;
+ top: 1rem;
+ right: .75rem;
+ width: .75rem;
+ height: .75rem;
+
+ background: $primary;
+ border-radius: 100%;
+ border: 2px solid $white;
+ }
+ }
+
+ .notifications-list {
+ position: fixed;
+ right: 1rem;
+ margin-top: 1rem;
+ max-height: 400px;
+ overflow-y: auto;
+
+ background: $white;
+ width: 350px;
+ max-width: calc(100vw - 2rem);
+ padding: .75rem .25rem;
+ border-radius: $radius;
+ box-shadow: $shadow-sm;
+ font-size: .85rem;
+
+ @media screen and (max-width: $tablet) {
+ max-height: calc(100vh - 1rem - #{$navbar-height});
+ }
+
+ .head {
+ font-family: $vikunja-font;
+ font-size: 1rem;
+ padding: .5rem;
+ }
+
+ .single-notification {
+ display: flex;
+ align-items: center;
+
+ transition: background-color $transition;
+
+ &:hover {
+ background: $grey-100;
+ border-radius: $radius;
+ }
+
+ .read-indicator {
+ width: .35rem;
+ height: .35rem;
+ background: $primary;
+ border-radius: 100%;
+ margin-left: .5rem;
+
+ &.read {
+ background: transparent;
+ }
+ }
+
+ .user {
+ display: flex;
+ align-items: center;
+ width: auto;
+ margin-right: .25rem;
+
+ span {
+ font-family: $family-sans-serif;
+ }
+
+ .avatar {
+ height: 16px;
+ }
+ }
+
+ .detail .created {
+ color: $grey-400;
+ }
+
+ &:last-child {
+ margin-bottom: .25rem;
+ }
+
+ a {
+ color: $grey-800;
+ }
+ }
+
+ .nothing {
+ text-align: center;
+ padding: 1rem 0;
+ color: $grey-500;
+
+ .explainer {
+ font-size: .75rem;
+ }
+ }
+ }
+}
diff --git a/src/styles/theme/navigation.scss b/src/styles/theme/navigation.scss
index db98fedd..cd327d2f 100644
--- a/src/styles/theme/navigation.scss
+++ b/src/styles/theme/navigation.scss
@@ -29,6 +29,7 @@
.navbar-end {
margin-left: 0;
align-items: center;
+ display: flex;
}
@media screen and (max-width: $desktop) {