diff --git a/frontend/src/components/TimeAgo.vue b/frontend/src/components/TimeAgo.vue
new file mode 100644
index 00000000..99426ca4
--- /dev/null
+++ b/frontend/src/components/TimeAgo.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 77144ba0..d6de9ac0 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -43,7 +43,6 @@ import App from './App.svelte';
import ColorTheme from './ColorTheme.svelte';
import CtrlP from './CtrlP.svelte';
import { safeMarkdown } from './markdown.js';
-import Notifications from './Notifications.svelte';
import PipelineStatus from './PipelineStatus.svelte';
import TaskDetail from './TaskDetail.svelte';
import UploadSolution from './UploadSolution.svelte';
@@ -113,9 +112,16 @@ function createElement(name, component) {
);
}
+const getCookies = () => {
+ return Object.fromEntries(document.cookie.split('; ').map((cookie) => cookie.split('=')));
+};
+
+const cookies = getCookies();
+/* eslint-disable @typescript-eslint/no-unused-vars */
+const enableNewUI = Object.keys(cookies).includes('newUI') && cookies['newUI'] != 0;
+
createElement('app', App);
createElement('submit-sources', TaskDetail);
-createElement('notifications', Notifications);
createElement('upload-solution', UploadSolution);
createElement('pipeline-status', PipelineStatus);
createElement('ctrlp', CtrlP);
@@ -147,6 +153,7 @@ import { defineCustomElement, h } from 'vue';
import SuspensionWrapper from './components/SuspensionWrapper.vue';
import AllTasks from './Teacher/AllTasks.vue';
import InbusImport from './Teacher/InbusImport.vue';
+import NotificationsNew from './components/Notifications.vue';
/**
* Register new Vue component as a custom element.
@@ -184,3 +191,4 @@ const registerSuspendedVueComponent = (name, component, configureApp = undefined
registerSuspendedVueComponent('tasks-all', AllTasks);
registerSuspendedVueComponent('inbus-import', InbusImport);
+registerVueComponent('notifications', NotificationsNew);
diff --git a/frontend/src/notifications.js b/frontend/src/notifications.js
deleted file mode 100644
index 911a250e..00000000
--- a/frontend/src/notifications.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { writable, derived } from 'svelte/store';
-import { fetch } from './api.js';
-
-export const notifications = (function () {
- const { subscribe, set } = writable([]);
-
- async function refresh() {
- const res = await fetch('/notification/all');
- set((await res.json())['notifications']);
- }
-
- refresh();
-
- return {
- subscribe,
- markRead: async (id) => {
- const res = await fetch('/notification/mark_as_read/' + id, { method: 'POST' });
- set((await res.json())['notifications']);
- },
- markAllRead: async () => {
- const res = await fetch('/notification/mark_as_read', { method: 'POST' });
- set((await res.json())['notifications']);
- }
- };
-})();
-
-export const pushNotifications = (function () {
- const { subscribe, update } = writable({
- supported: false,
- enabled: null
- });
-
- function getPublicKey() {
- return document.querySelector('meta[name="django-webpush-vapid-key"]').content;
- }
-
- function urlB64ToUint8Array(base64String) {
- const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
- const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
-
- const rawData = window.atob(base64);
- const outputArray = new Uint8Array(rawData.length);
-
- for (var i = 0; i < rawData.length; ++i) {
- outputArray[i] = rawData.charCodeAt(i);
- }
- return outputArray;
- }
-
- const subscribeOpts = {
- userVisibleOnly: true,
- applicationServerKey: urlB64ToUint8Array(getPublicKey())
- };
-
- async function getSubscription() {
- if (!reg) {
- return null;
- }
-
- try {
- let sub = await reg.pushManager.getSubscription();
- if (sub) {
- return sub;
- }
-
- sub = await reg.pushManager.subscribe(subscribeOpts);
-
- const browser = navigator.userAgent
- .match(/(firefox|msie|chrome|safari|trident)/gi)[0]
- .toLowerCase();
- const data = {
- status_type: 'subscribe',
- subscription: sub.toJSON(),
- browser: browser,
- group: null
- };
-
- await fetch('/webpush/save_information', {
- method: 'post',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(data)
- });
- return sub;
- } catch (exception) {
- console.log(exception);
- }
-
- return null;
- }
-
- async function subscribePushNotifications() {
- const isEnabled = (await getSubscription()) !== null;
- update((s) => {
- s.enabled = isEnabled;
- return s;
- });
- return isEnabled;
- }
-
- let reg = null;
- if ('serviceWorker' in navigator && 'PushManager' in window && getPublicKey()) {
- (async () => {
- try {
- reg = await navigator.serviceWorker.register('/static/service-worker.js');
- if (!reg.showNotification) {
- return;
- }
- update((s) => {
- s.supported = true;
- return s;
- });
- subscribePushNotifications();
- } catch (err) {
- console.log(err);
- }
- })();
- }
-
- return {
- subscribe,
- subscribePushNotifications
- };
-})();
-
-export const notificationsCount = derived(
- notifications,
- ($notifications) => $notifications.filter((n) => n.unread).length
-);
-export const importantNotificationsCount = derived(
- notifications,
- ($notifications) => $notifications.filter((n) => n.important && n.unread).length
-);
diff --git a/frontend/src/utilities.ts b/frontend/src/utilities.ts
deleted file mode 100644
index 012e7c16..00000000
--- a/frontend/src/utilities.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
-
-/**
- * Get data from API
- * @param url url to fetch
- * @param data data which will be sent to the API
- * @param method HTTP method, if you want to override the default GET/POST
- * @note If data is passed, the request will be a POST request, otherwise a GET request, if not overridden by method parameter
- * @returns $ReturnType, if the request was successful, otherwise undefined
- */
-export const getFromAPI = async <$ReturnType>(
- url: string,
- data?: unknown,
- method?: Method
-): Promise<$ReturnType | undefined> => {
- try {
- const response = await fetch(url, {
- method: method || (data ? 'POST' : 'GET'),
- body: data ? JSON.stringify(data) : undefined
- });
-
- if (!response.ok) {
- return undefined;
- }
-
- const json = await response.json();
- return json as $ReturnType;
- } catch (error) {
- console.error(error);
- return undefined;
- }
-};
-
-/**
- * Generate array with range of numbers from start
- * @param size size of the array
- * @param start starting number
- * @returns array of numbers from start to start + size
- */
-export const generateRange = (size: number, start = 0) => {
- return Array.from({ length: size }).map((_, i) => i + start);
-};
diff --git a/frontend/src/utilities/api.ts b/frontend/src/utilities/api.ts
new file mode 100644
index 00000000..bbe1fbb0
--- /dev/null
+++ b/frontend/src/utilities/api.ts
@@ -0,0 +1,64 @@
+export function csrfToken() {
+ return document.querySelector('meta[name=csrf-token]').getAttribute('content');
+}
+
+type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
+
+/**
+ * Get data from API
+ *
+ * @param url url to fetch
+ * @param data data which will be sent to the API
+ * @param method HTTP method, if you want to override the default GET/POST
+ * @note If data is passed, the request will be a POST request, otherwise a GET request, if not overridden by method parameter
+ * @param headers Headers for the request
+ *
+ * @returns $ReturnType, if the request was successful, otherwise undefined
+ */
+export const getFromAPI = async <$ReturnType>(
+ url: string,
+ method?: Method,
+ data?: unknown,
+ headers?: HeadersInit
+): Promise<$ReturnType | undefined> => {
+ try {
+ const response = await fetch(url, {
+ method: method || (data ? 'POST' : 'GET'),
+ headers,
+ body: data ? JSON.stringify(data) : undefined
+ });
+
+ if (!response.ok) {
+ return undefined;
+ }
+
+ const json = await response.json();
+ return json as $ReturnType;
+ } catch (error) {
+ console.error(error);
+ return undefined;
+ }
+};
+
+/**
+ * Get data from endpoint with {@link getFromAPI()}, but with already filled header `X-CSRFToken`.
+ *
+ * @param url url to fetch
+ * @param data data which will be sent to the API
+ * @param method HTTP method, if you want to override the default GET/POST
+ * @note If data is passed, the request will be a POST request, otherwise a GET request, if not overridden by method parameter
+ * @param headers Headers for the request
+ *
+ * @returns $ReturnType, if the request was successful, otherwise undefined
+ */
+export const getDataWithCSRF = async <$ReturnType>(
+ url: string,
+ method?: Method,
+ data?: unknown,
+ headers?: HeadersInit
+): Promise<$ReturnType | undefined> => {
+ const CSRF = {
+ 'X-CSRFToken': csrfToken()
+ };
+ return getFromAPI(url, method, data, headers ? { ...headers, ...CSRF } : CSRF);
+};
diff --git a/frontend/src/utilities/notifications.ts b/frontend/src/utilities/notifications.ts
new file mode 100644
index 00000000..d5f333a7
--- /dev/null
+++ b/frontend/src/utilities/notifications.ts
@@ -0,0 +1,152 @@
+import { computed } from 'vue';
+import { ref } from 'vue';
+import { getDataWithCSRF } from './api';
+
+//@TODO: complete null unions if missing
+export type Notification = {
+ id: number;
+ level: 'info'; //@TODO: add other levels
+ recepient: number;
+ unread: boolean;
+ actor_content_type: number;
+ actor_object_id: string;
+ verb: string;
+ description: string | null;
+ timestamp: string;
+ public: boolean;
+ deleted: boolean;
+ emailed: boolean;
+ important: boolean;
+ actor: string;
+ action_object: string;
+ action_object_url: string;
+ custom_text?: string;
+ target?: string;
+};
+
+export const notifications = (function () {
+ const notificationsRef = ref([]);
+
+ const refresh = async () => {
+ const data = await getDataWithCSRF<{ notifications: Notification[] }>('/notification/all');
+ notificationsRef.value = data.notifications;
+ };
+
+ refresh();
+
+ return {
+ notificationsRef,
+ markRead: async (id: number) => {
+ const data = await getDataWithCSRF<{ notifications: Notification[] }>(
+ '/notification/mark_as_read/' + id,
+ 'POST'
+ );
+ notificationsRef.value = data.notifications;
+ },
+ markAllRead: async () => {
+ const data = await getDataWithCSRF<{ notifications: Notification[] }>(
+ '/notification/mark_as_read',
+ 'POST'
+ );
+ notificationsRef.value = data.notifications;
+ }
+ };
+})();
+
+export const pushNotifications = (function () {
+ const pushNotificationsStatus = ref({
+ supported: false,
+ enabled: null
+ });
+
+ function getPublicKey() {
+ return document.querySelector('meta[name="django-webpush-vapid-key"]')
+ .content;
+ }
+
+ function urlB64ToUint8Array(base64String: string) {
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+ }
+
+ const subscribeOpts = {
+ userVisibleOnly: true,
+ applicationServerKey: urlB64ToUint8Array(getPublicKey())
+ };
+
+ async function getSubscription() {
+ if (!reg) {
+ return null;
+ }
+
+ try {
+ let sub = await reg.pushManager.getSubscription();
+ if (sub) {
+ return sub;
+ }
+
+ sub = await reg.pushManager.subscribe(subscribeOpts);
+
+ const browser = navigator.userAgent
+ .match(/(firefox|msie|chrome|safari|trident)/gi)[0]
+ .toLowerCase();
+ const data = {
+ status_type: 'subscribe',
+ subscription: sub.toJSON(),
+ browser: browser,
+ group: null
+ };
+
+ await getDataWithCSRF('/webpush/save_information', 'POST', data, {
+ 'Content-Type': 'application/json'
+ });
+ return sub;
+ } catch (exception) {
+ console.log(exception);
+ }
+
+ return null;
+ }
+
+ async function subscribePushNotifications() {
+ const isEnabled = (await getSubscription()) !== null;
+ pushNotificationsStatus.value.enabled = isEnabled;
+ return isEnabled;
+ }
+
+ let reg: null | ServiceWorkerRegistration = null;
+ if ('serviceWorker' in navigator && 'PushManager' in window && getPublicKey()) {
+ (async () => {
+ try {
+ reg = await navigator.serviceWorker.register('/static/service-worker.js');
+ if (!reg.showNotification) {
+ return;
+ }
+ pushNotificationsStatus.value.supported = true;
+ subscribePushNotifications();
+ } catch (err) {
+ console.log(err);
+ }
+ })();
+ }
+
+ return {
+ ref: pushNotificationsStatus,
+ subscribePushNotifications
+ };
+})();
+
+export const notificationsCount = computed(
+ () => notifications.notificationsRef.value.filter((n) => n.unread).length
+);
+export const importantNotificationsCount = computed(
+ () => notifications.notificationsRef.value.filter((n) => n.important && n.unread).length
+);
diff --git a/frontend/src/utilities/storage.ts b/frontend/src/utilities/storage.ts
new file mode 100644
index 00000000..f84b4aa0
--- /dev/null
+++ b/frontend/src/utilities/storage.ts
@@ -0,0 +1,30 @@
+import { ref, watch, type Ref } from 'vue';
+
+const localStorageStores: Record> = {};
+
+/**
+ * Returns ref to value in localStorage. When updated, automatically saved to localStorage.
+ *
+ * @param key Key of localStorage item
+ * @param initialValue Initial value of item
+ *
+ * @returns {@link Ref} to localStorage value
+ */
+export const localStorageStore = <$Type>(key: string, initialValue: $Type): Ref<$Type> => {
+ if (!(key in localStorageStores)) {
+ const saved = localStorage.getItem(key);
+ if (saved) {
+ try {
+ initialValue = JSON.parse(saved);
+ } catch (exception) {
+ console.log(`Failed to load ${key}: ${exception}`);
+ }
+ }
+
+ const value = ref(initialValue);
+
+ localStorageStores[key] = value;
+ watch(value, (newValue) => localStorage.setItem(key, JSON.stringify(newValue)));
+ }
+ return localStorageStores[key] as Ref<$Type>;
+};