diff --git a/.gitignore b/.gitignore
index 64c4fad..0041fbb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@ juggl/config/config.txt
juggl/config/config.path
juggl/config/config.php
graphics
-.vscode
\ No newline at end of file
+.vscode
+juggl-vue/package-lock.json
diff --git a/juggl-server/api/getRecords.php b/juggl-server/api/getRecords.php
new file mode 100644
index 0000000..2c69466
--- /dev/null
+++ b/juggl-server/api/getRecords.php
@@ -0,0 +1,29 @@
+get("user_id");
+
+ $records = getRecords($user_id);
+
+ $json = new JsonBuilder();
+ $json->addRecords($records);
+
+ respondJson($json);
+ }
+}
+
+$branch = new GetRecordsBranch();
+$branch->execute();
diff --git a/juggl-server/api/getUser.php b/juggl-server/api/getUser.php
new file mode 100644
index 0000000..888f8bb
--- /dev/null
+++ b/juggl-server/api/getUser.php
@@ -0,0 +1,29 @@
+get("user_id");
+
+ $user = getUser($user_id);
+
+ $json = new JsonBuilder();
+ $json->addUsers([$user]);
+
+ respondJson($json);
+ }
+}
+
+$branch = new GetUserBranch();
+$branch->execute();
diff --git a/juggl-server/api/services/jsonBuilder.inc.php b/juggl-server/api/services/jsonBuilder.inc.php
index 4827fe8..aeaa101 100644
--- a/juggl-server/api/services/jsonBuilder.inc.php
+++ b/juggl-server/api/services/jsonBuilder.inc.php
@@ -57,6 +57,23 @@ class JsonBuilder
return $this;
}
+ function addUsers(array $users)
+ {
+ if ($users === null) return;
+
+ $columns = array(
+ "user_id" => "",
+ "name" => "",
+ "mail_address" => ""
+ );
+
+ $this->jsonData['users'] = array();
+ foreach ($users as $user) {
+ $this->jsonData['users'][] = $this->createJsonArray($user, $columns);
+ }
+ return $this;
+ }
+
function addRecordTags(array $record_tags)
{
if ($record_tags === null) return;
diff --git a/juggl-server/api/services/jugglDbApi.inc.php b/juggl-server/api/services/jugglDbApi.inc.php
index e5333af..c21765a 100644
--- a/juggl-server/api/services/jugglDbApi.inc.php
+++ b/juggl-server/api/services/jugglDbApi.inc.php
@@ -67,6 +67,21 @@ function getProjects($user_id)
return $results;
}
+function getUser($user_id)
+{
+ $db = new DbOperations();
+ $db->select("users", ["user_id", "name", "mail_address"]);
+ $db->where("user_id", Comparison::EQUAL, $user_id);
+ $result = $db->execute();
+
+ if (count($result) <= 0) {
+ return null;
+ }
+ $result = $result[0];
+
+ return $result;
+}
+
function getProjectRecordDerivedData($user_id, $project_id)
{
$durationAttribute = "SUM(duration) AS total_duration";
@@ -132,6 +147,21 @@ function getRunningRecords($user_id)
return $results;
}
+function getRecords($user_id)
+{
+ $db = new DbOperations();
+ $db->select("time_records");
+ $db->where("user_id", Comparison::EQUAL, $user_id);
+ $results = $db->execute();
+
+ // Is still running?
+ foreach ($results as $key => $record) {
+ $results[$key] = getRecordExternalData($record);
+ }
+
+ return $results;
+}
+
function updateEndRecord($user_id, $params)
{
$record_id = $params->get("record_id");
@@ -196,9 +226,12 @@ function getRecordExternalData($record)
return null;
}
- // Duration
+ // Duration and running
if ($record["end_time"] == NULL) {
$record["duration"] = calcDuration($record["start_time"]);
+ $record["running"] = true;
+ } else {
+ $record["running"] = false;
}
// Tags
diff --git a/juggl-vue/.browserslistrc b/juggl-vue/.browserslistrc
new file mode 100644
index 0000000..214388f
--- /dev/null
+++ b/juggl-vue/.browserslistrc
@@ -0,0 +1,3 @@
+> 1%
+last 2 versions
+not dead
diff --git a/juggl-vue/.eslintrc.js b/juggl-vue/.eslintrc.js
new file mode 100644
index 0000000..5592b18
--- /dev/null
+++ b/juggl-vue/.eslintrc.js
@@ -0,0 +1,14 @@
+module.exports = {
+ root: true,
+ env: {
+ node: true
+ },
+ extends: ["plugin:vue/essential", "eslint:recommended", "@vue/prettier"],
+ parserOptions: {
+ parser: "babel-eslint"
+ },
+ rules: {
+ "no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
+ "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off"
+ }
+};
diff --git a/juggl-vue/.gitignore b/juggl-vue/.gitignore
new file mode 100644
index 0000000..403adbc
--- /dev/null
+++ b/juggl-vue/.gitignore
@@ -0,0 +1,23 @@
+.DS_Store
+node_modules
+/dist
+
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/juggl-vue/README.md b/juggl-vue/README.md
new file mode 100644
index 0000000..822638e
--- /dev/null
+++ b/juggl-vue/README.md
@@ -0,0 +1,24 @@
+# juggl-vue
+
+## Project setup
+```
+npm install
+```
+
+### Compiles and hot-reloads for development
+```
+npm run serve
+```
+
+### Compiles and minifies for production
+```
+npm run build
+```
+
+### Lints and fixes files
+```
+npm run lint
+```
+
+### Customize configuration
+See [Configuration Reference](https://cli.vuejs.org/config/).
diff --git a/juggl-vue/babel.config.js b/juggl-vue/babel.config.js
new file mode 100644
index 0000000..397abca
--- /dev/null
+++ b/juggl-vue/babel.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ presets: ["@vue/cli-plugin-babel/preset"]
+};
diff --git a/juggl-vue/package.json b/juggl-vue/package.json
new file mode 100644
index 0000000..bbc1528
--- /dev/null
+++ b/juggl-vue/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "juggl-vue",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "serve": "vue-cli-service serve",
+ "build": "vue-cli-service build",
+ "lint": "vue-cli-service lint"
+ },
+ "dependencies": {
+ "axios": "^0.21.0",
+ "bootstrap": "^4.5.3",
+ "bootstrap-vue": "^2.21.1",
+ "core-js": "^3.6.5",
+ "vue": "^2.6.11",
+ "vue-router": "^3.2.0",
+ "vuex": "^3.4.0"
+ },
+ "devDependencies": {
+ "@vue/cli-plugin-babel": "~4.5.0",
+ "@vue/cli-plugin-eslint": "~4.5.0",
+ "@vue/cli-plugin-router": "~4.5.0",
+ "@vue/cli-plugin-vuex": "~4.5.0",
+ "@vue/cli-service": "~4.5.0",
+ "@vue/eslint-config-prettier": "^6.0.0",
+ "babel-eslint": "^10.1.0",
+ "eslint": "^6.7.2",
+ "eslint-plugin-prettier": "^3.1.3",
+ "eslint-plugin-vue": "^6.2.2",
+ "node-sass": "^4.12.0",
+ "prettier": "^1.19.1",
+ "sass-loader": "^8.0.2",
+ "vue-template-compiler": "^2.6.11"
+ }
+}
diff --git a/juggl-vue/public/favicon.ico b/juggl-vue/public/favicon.ico
new file mode 100644
index 0000000..a1e7893
Binary files /dev/null and b/juggl-vue/public/favicon.ico differ
diff --git a/juggl-vue/public/index.html b/juggl-vue/public/index.html
new file mode 100644
index 0000000..0cb0e4a
--- /dev/null
+++ b/juggl-vue/public/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+ Juggl
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/juggl-vue/src/App.vue b/juggl-vue/src/App.vue
new file mode 100644
index 0000000..5e92239
--- /dev/null
+++ b/juggl-vue/src/App.vue
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/juggl-vue/src/assets/logo.png b/juggl-vue/src/assets/logo.png
new file mode 100644
index 0000000..eb257ee
Binary files /dev/null and b/juggl-vue/src/assets/logo.png differ
diff --git a/juggl-vue/src/components/base/BaseContainer.vue b/juggl-vue/src/components/base/BaseContainer.vue
new file mode 100644
index 0000000..90ea603
--- /dev/null
+++ b/juggl-vue/src/components/base/BaseContainer.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/juggl-vue/src/components/base/BaseLogo.vue b/juggl-vue/src/components/base/BaseLogo.vue
new file mode 100644
index 0000000..c6f5f7a
--- /dev/null
+++ b/juggl-vue/src/components/base/BaseLogo.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/juggl-vue/src/components/base/BaseTitle.vue b/juggl-vue/src/components/base/BaseTitle.vue
new file mode 100644
index 0000000..26735ae
--- /dev/null
+++ b/juggl-vue/src/components/base/BaseTitle.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
diff --git a/juggl-vue/src/components/forms/FormLogin.vue b/juggl-vue/src/components/forms/FormLogin.vue
new file mode 100644
index 0000000..35065e2
--- /dev/null
+++ b/juggl-vue/src/components/forms/FormLogin.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+ Password or email invalid.
+
+
+ Log in
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/juggl-vue/src/components/layout/LayoutMinimal.vue b/juggl-vue/src/components/layout/LayoutMinimal.vue
new file mode 100644
index 0000000..943350d
--- /dev/null
+++ b/juggl-vue/src/components/layout/LayoutMinimal.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+
diff --git a/juggl-vue/src/components/layout/LayoutNavbarPrivate.vue b/juggl-vue/src/components/layout/LayoutNavbarPrivate.vue
new file mode 100644
index 0000000..2e0c961
--- /dev/null
+++ b/juggl-vue/src/components/layout/LayoutNavbarPrivate.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/juggl-vue/src/main.js b/juggl-vue/src/main.js
new file mode 100644
index 0000000..69cdc10
--- /dev/null
+++ b/juggl-vue/src/main.js
@@ -0,0 +1,17 @@
+import Vue from "vue";
+import App from "./App.vue";
+import router from "./router";
+import store from "./store";
+import { BootstrapVue, BootstrapVueIcons } from "bootstrap-vue";
+
+Vue.config.productionTip = false;
+
+// Install BootstrapVue
+Vue.use(BootstrapVue);
+Vue.use(BootstrapVueIcons);
+
+new Vue({
+ router,
+ store,
+ render: (h) => h(App),
+}).$mount("#app");
diff --git a/juggl-vue/src/router/index.js b/juggl-vue/src/router/index.js
new file mode 100644
index 0000000..3cb1395
--- /dev/null
+++ b/juggl-vue/src/router/index.js
@@ -0,0 +1,43 @@
+import Vue from "vue";
+import VueRouter from "vue-router";
+import Login from "../views/Login.vue";
+import Home from "../views/Home.vue";
+
+Vue.use(VueRouter);
+
+const routes = [
+ {
+ path: "/login",
+ name: "Login",
+ component: Login,
+ },
+ {
+ path: "/",
+ name: "Home",
+ component: Home,
+ // beforeEnter: requireAuth,
+ },
+];
+
+const router = new VueRouter({
+ mode: "history",
+ base: process.env.BASE_URL,
+ routes,
+});
+
+/**
+ * Checks authentication before proceeding
+ */
+// TODO: Authentication required in router
+// function requireAuth(to, from, next) {
+// if (!userService.isLoggedIn()) {
+// next({
+// path: "/login",
+// query: { redirect: to.fullPath },
+// });
+// } else {
+// next();
+// }
+// }
+
+export default router;
diff --git a/juggl-vue/src/services/api.service.js b/juggl-vue/src/services/api.service.js
new file mode 100644
index 0000000..b91b380
--- /dev/null
+++ b/juggl-vue/src/services/api.service.js
@@ -0,0 +1,35 @@
+import axios from "axios";
+import { store } from "@/store";
+
+/**
+ * A wrapper for the used fetch API, currently axios.
+ * Uses some default values from the config (e.g. ApiUrl).
+ *
+ * Authentication already integrated.
+ *
+ * Returns promises.
+ */
+export const apiService = {
+ get(resource, options) {
+ return this.getApi().get(resource, options);
+ },
+
+ post(resource, json, options) {
+ return this.getApi().post(resource, json, options);
+ },
+
+ put(resource, json, options) {
+ return this.getApi().put(resource, json, options);
+ },
+
+ /**
+ * Creates an instance of the used api and sets necessary headers
+ */
+ getApi() {
+ var options = {
+ baseURL: store.getters.apiUrl,
+ };
+
+ return axios.create(options);
+ },
+};
diff --git a/juggl-vue/src/services/helper.service.js b/juggl-vue/src/services/helper.service.js
new file mode 100644
index 0000000..babd7e8
--- /dev/null
+++ b/juggl-vue/src/services/helper.service.js
@@ -0,0 +1,113 @@
+// TODO: Do I need this file?
+
+/**
+ * Contains a bunch of helper functions.
+ */
+export const helperService = {
+ /**
+ * Converts number into a human readable percent integer.
+ *
+ * @param {*} r Floating point percent number
+ */
+ asPercent(r) {
+ return Math.floor(r * 100);
+ },
+
+ /**
+ * Converts number into a human readable floating point number with a digit behind the delimiter.
+ *
+ * @param {*} r Floating point number
+ */
+ asFloat(r) {
+ return Math.floor(r * 10) / 10;
+ },
+
+ /**
+ * Converts timestamp into a human readable date format.
+ *
+ * @param {*} d ISO Timestamp
+ */
+ asDateFromISO(d) {
+ return helperService.asDateFromObj(new Date(d));
+ },
+
+ /**
+ * Converts timestamp into a human readable date format.
+ *
+ * @param {*} d Date object
+ */
+ asDateFromObj(d) {
+ return new Intl.DateTimeFormat("de").format(d);
+ },
+
+ /**
+ * Makes sure, that the given text is not too long.
+ *
+ * @param {*} text Some text that might be shortened
+ * @param {*} maxLength Max number of characters
+ *
+ * @returns The shortened or same text
+ */
+ keepItShort(text, maxLength = 20, ellipsis = "...") {
+ if (text.length > maxLength) {
+ // Adding ellipsis
+ var short = text.substring(0, maxLength - ellipsis.length).trim();
+ return short + ellipsis;
+ } else return text;
+ },
+
+ /**
+ * Converts a date object into a date-time-timezone string, and sets times and timezone to zero.
+ *
+ * @source https://stackoverflow.com/a/43528844/7376120
+ *
+ * @param {*} date A date object
+ *
+ * @returns Date as string in the used format
+ */
+ toISODate(date) {
+ var timezoneOffset = date.getMinutes() + date.getTimezoneOffset();
+ var timestamp = date.getTime() + timezoneOffset * 1000;
+ var correctDate = new Date(timestamp);
+
+ correctDate.setUTCHours(0, 0, 0, 0);
+
+ return correctDate.toISOString();
+ },
+
+ /**
+ * Adds current duration to a record.
+ * Copied from original juggl code.
+ * @param {*} record The record instance to update.
+ */
+ addDuration(record) {
+ if (record.end_time != null) return record;
+
+ record.duration =
+ (new Date().getTime() - new Date(record.start_time).getTime()) /
+ 1000;
+
+ return record;
+ },
+
+ /**
+ * Converts a datetime object into the necessary string format for server requests.
+ * Copied from original juggl code.
+ * @param {*} date
+ */
+ dateToString(date) {
+ return (
+ date.getFullYear() +
+ "-" +
+ (date.getMonth() + 1) +
+ "-" +
+ date.getDate() +
+ " " +
+ date.getHours() +
+ ":" +
+ date.getMinutes() +
+ ":" +
+ date.getSeconds()
+ );
+ },
+};
diff --git a/juggl-vue/src/services/juggl.service.js b/juggl-vue/src/services/juggl.service.js
new file mode 100644
index 0000000..51bab88
--- /dev/null
+++ b/juggl-vue/src/services/juggl.service.js
@@ -0,0 +1,222 @@
+import { apiService } from "@/services/api.service";
+import { helperService } from "@/services/helper.service";
+
+/**
+ * A collection of functions to retreive and send all user-specific data.
+ */
+export const jugglService = {
+ /**
+ * Fetches the user from the API.
+ *
+ * @returns A promise
+ */
+ getUser() {
+ return apiService.post("/getUser.php");
+ },
+
+ getProjects() {
+ return apiService.post("/getProjects.php");
+ },
+
+ getRecords() {
+ return apiService.post("/getRecords.php");
+ },
+
+ getRunningRecords() {
+ return apiService.post("/getRunningRecords.php");
+ },
+
+ startRecord(projectId, startTime = null) {
+ if (startTime == null) startTime = new Date();
+ return apiService.post("/startRecord.php", {
+ project_id: projectId,
+ start_time: helperService.dateToString(startTime),
+ });
+ },
+
+ /**
+ * Requests a list of all semesters from the current user.
+ *
+ * @returns A promise
+ */
+ getSemesterList() {
+ return apiService.get("/semester");
+ },
+
+ /**
+ * Requests all the data from a specific semester from the current user.
+ *
+ * @param {*} semesterNr Nr starting at 1 from the desired semester to gather data from
+ *
+ * @returns A promise
+ */
+ getSemester(semesterNr) {
+ var path = "/semester/" + semesterNr;
+ return apiService.get(path).then((semester) => {
+ // Add semester nr to modules
+ semester.data.modules.forEach((module) => {
+ module.semester_nr = semester.nr;
+ });
+
+ return semester;
+ });
+ },
+
+ /**
+ * Adds a new semester to the current user.
+ *
+ * @param {*} data An object with {nr, startdate, enddate}
+ *
+ * @returns A promise
+ */
+ addSemester(data) {
+ var path = "/semester";
+ return apiService.put(path, data);
+ },
+
+ /**
+ * Adds a new module to the current user.
+ *
+ * @param {*} semesterNr Associated semester nr
+ * @param {*} data An object with {name, required_total, required_per_sheet}
+ *
+ * @returns A promise
+ */
+ addModule(semesterNr, data) {
+ var path = "/semester/" + semesterNr + "/module";
+ return apiService.put(path, data);
+ },
+
+ /**
+ * Adds a new sheet to the current user.
+ *
+ * @param {*} semesterNr Associated semester nr
+ * @param {*} semesterNr Associated module name
+ * @param {*} data An object with {date, points, total, is_draft, percentage}
+ *
+ * @returns A promise
+ */
+ addSheet(semesterNr, moduleName, data) {
+ var path =
+ "/semester/" + semesterNr + "/module/" + moduleName + "/sheet";
+ return apiService.put(path, data);
+ },
+
+ /**
+ * Registers a new user.
+ *
+ * @param {*} data An object with {date, points, total, is_draft, percentage}
+ *
+ * @returns A promise
+ */
+ signUp(data) {
+ var path = "/user/signup";
+ return apiService
+ .post(path, data)
+ .then((r) => {
+ return { success: true, msg: r.data.msg };
+ // Later
+ // if (r.status === 204) return { success: true, msg: "" };
+ // else return { success: false, msg: r.data.msg };
+ })
+ .catch((r) => {
+ return { success: false, msg: r.response.data.msg };
+ });
+ },
+
+ /**
+ * Verifies a user with a token.
+ *
+ * @param {*} token Server generated token
+ *
+ * @returns A promise
+ */
+ verify(token) {
+ var path = "/user/verify";
+ return apiService
+ .post(path, { token: token })
+ .then((r) => {
+ return { success: true, msg: r.data.msg };
+ })
+ .catch((r) => {
+ return { success: false, msg: r.response.data.msg };
+ });
+ },
+
+ /**
+ * Requests all the data from a specific semester from the current user.
+ *
+ * @param {*} semesterNr Nr starting at 1 from the desired semester to gather data from
+ *
+ * @returns A promise
+ */
+ getModule(semesterNr, moduleName) {
+ var path = "/semester/" + semesterNr + "/module/" + moduleName;
+
+ return apiService.get(path).then((module) => {
+ module.data.semester_nr = semesterNr;
+
+ // Add semester nr and module name to sheets
+ module.data.sheets.forEach((sheet) => {
+ sheet.semester_nr = semesterNr;
+ sheet.module_name = moduleName;
+ });
+
+ return module;
+ });
+ },
+
+ /**
+ * Sends credentials to the server in case of successful authentication, saves the jwt in localStorage.
+ *
+ * @param {*} email Email of user credentials
+ * @param {*} pass Password of user credentials
+ * @returns A promise with on parameter, which is true in case of successful authentication
+ */
+ login(email, pass) {
+ // Is already logged in?
+ if (this.isLoggedIn()) {
+ this.logout();
+ }
+
+ // Try to log in
+ var user = {
+ email: email,
+ password: pass,
+ };
+
+ return apiService
+ .post("/user/login", user)
+ .then((r) => {
+ localStorage.token = r.data;
+ return true;
+ })
+ .catch(() => {
+ return false;
+ });
+ },
+
+ /**
+ * Returns the jwt.
+ */
+ getToken() {
+ return localStorage.token;
+ },
+
+ /**
+ * Removes the stored jwt and therefor prevents access to any page that requires authentication.
+ */
+ logout() {
+ delete localStorage.token;
+ apiService.get("/user/logout");
+ },
+
+ /**
+ * Returns the current authentication state.
+ */
+ isLoggedIn() {
+ // This !! converts expression to boolean with unchanged value
+ // First ! converts to boolean and inverts value and second ! inverts again
+ return !!localStorage.token;
+ },
+};
diff --git a/juggl-vue/src/services/path.service.js b/juggl-vue/src/services/path.service.js
new file mode 100644
index 0000000..a99edf4
--- /dev/null
+++ b/juggl-vue/src/services/path.service.js
@@ -0,0 +1,144 @@
+import router from "@/router";
+import { userService } from "@/services/user.service";
+
+// TODO: Do I need this file?
+/**
+ * Contains a bunch of path helper functions.
+ */
+export const pathService = {
+ /**
+ * Redirects to the not found page.
+ *
+ * @param {*} reason Optional: A reason to explain to the user, why they got redirected
+ * @param {*} sourceRoute Optional: Adds additional information from the source route to the query
+ */
+ notFound(reason, sourceRoute) {
+ var payload = {};
+
+ if (reason !== undefined) {
+ payload.reason = reason;
+ }
+
+ if (sourceRoute !== undefined) {
+ payload.source = sourceRoute.path;
+ }
+
+ router.push({
+ path: "/404",
+ query: payload,
+ });
+ },
+
+ /**
+ * Validates and gets all data to a path of the form /dashboard/:sem:-sem
+ *
+ * @param {*} route Object that describes the current path and its parameters
+ *
+ * @returns A promise of the form ({semester: semester})
+ */
+ validateSemPath(route) {
+ var semesterNr = this.getSemesterNrFromParam(route.params.semesterName);
+
+ // Invalid path?
+ if (semesterNr === undefined) {
+ this.notFound("Invalid URL", route);
+ }
+
+ // Gather more data
+ return userService
+ .getSemester(semesterNr)
+ .then((semester) => {
+ return { semester: semester.data };
+ })
+ .catch(() => {
+ // Semester could not be found
+ this.notFound(semesterNr + ". semester not found", route);
+ });
+ },
+
+ /**
+ * Validates and gets all data to a path of the form /dashboard/:sem:-sem/:module:
+ *
+ * @param {*} route Object that describes the current path and its parameters
+ *
+ * @returns A promise of the form ({semester: semester, module: module})
+ */
+ validateSemModulePath(route) {
+ // First evaluate semester and than the module
+ return this.validateSemPath(route).then((obj) => {
+ var moduleName = route.params.moduleName;
+
+ // Gather more data
+ return userService
+ .getModule(obj.semester.nr, moduleName)
+ .then((module) => {
+ return { semester: obj.semester, module: module.data };
+ })
+ .catch(() => {
+ // Module could not be found
+ this.notFound(
+ '"' + moduleName + '" module not found',
+ route
+ );
+ });
+ });
+ },
+
+ /**
+ * Extracts the semester nr based on the url parameter in the format ":semesterNr:-sem".
+ *
+ * @param {*} semesterParam In the format ":semesterNr:-sem"
+ *
+ * @returns Semester Nr. if valid, otherwise undefined
+ */
+ getSemesterNrFromParam(semesterParam) {
+ let re = new RegExp("^([1-9][0-9]*)-sem$");
+ var res = semesterParam.match(re);
+
+ // Found a match?
+ if (res === null) {
+ return undefined; // No
+ } else {
+ // First group is semester nr
+ return res[1];
+ }
+ },
+
+ getBreadcrumbs(route) {
+ let sites = route.path.split("/");
+ let siteCount = sites.length;
+
+ return sites.reduce((crumbs, current, index) => {
+ // Ignore empty parts
+ if (current.length <= 0) {
+ return crumbs;
+ }
+
+ var name = current;
+ // Handle semester name
+ var semNr = this.getSemesterNrFromParam(current);
+ if (semNr !== undefined) {
+ name = semNr + ". Semester";
+ }
+ // Handle decoding and capitalization
+ name = decodeURIComponent(name);
+ name = name.charAt(0).toUpperCase() + name.slice(1);
+
+ let part = {
+ text: name,
+ };
+
+ let isActive = index + 1 == siteCount;
+ if (isActive) {
+ part.active = true;
+ } else {
+ part.to = crumbs[crumbs.length - 1]
+ ? crumbs[crumbs.length - 1].to + "/" + current
+ : "/" + current;
+ }
+
+ crumbs.push(part);
+ return crumbs;
+ }, []);
+ },
+};
diff --git a/juggl-vue/src/store/index.js b/juggl-vue/src/store/index.js
new file mode 100644
index 0000000..73a3d4d
--- /dev/null
+++ b/juggl-vue/src/store/index.js
@@ -0,0 +1,56 @@
+import Vue from "vue";
+import Vuex from "vuex";
+import { jugglService } from "@/services/juggl.service.js";
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ state: {
+ apiUrl: "https://juggl.giller.dev/api",
+ apiKey: undefined,
+ user: undefined,
+ projects: [],
+ records: [],
+ },
+ mutations: {
+ setKey(state, key) {
+ state.key = key;
+ },
+ setProjects(state, projects) {
+ state.projects = projects;
+ },
+ setRecords(state, records) {
+ state.records = records;
+ },
+ },
+ getters: {
+ runningRecords: (state) => {
+ return state.records.filter((record) => record.running);
+ },
+ finishedRecords: (state) => {
+ return state.records.filter((record) => !record.running);
+ },
+ apiKey: (state) => state.apiKey,
+ user: (state) => state.user,
+ apiUrl: (state) => state.apiUrl,
+ isLoggedIn: (state) => !!state.apiKey,
+ records: (state) => state.records,
+ projects: (state) => state.projects,
+ getProjectById: (state, getters) => (id) => {
+ return getters.projects.find(
+ (project) => project.project_id === id
+ );
+ },
+ getRecordById: (state, getters) => (id) => {
+ return getters.records.find((record) => record.record_id === id);
+ },
+ },
+ actions: {
+ loadProjects({ commit }) {
+ commit("setProjects");
+ },
+ login({ commit, userId, apiKey }) {
+ return jugglService.login();
+ },
+ },
+});
diff --git a/juggl-vue/src/style/theme.sass b/juggl-vue/src/style/theme.sass
new file mode 100644
index 0000000..063408c
--- /dev/null
+++ b/juggl-vue/src/style/theme.sass
@@ -0,0 +1,93 @@
+@import url('https://fonts.googleapis.com/css2?family=Karla:ital,wght@0,400;0,700;1,400;1,700&display=swap')
+
+// Color scheme
+$grey: #BBB
+$black: #000
+$white: #fff
+$primary: #F00
+$secondary: $grey
+
+$background-primary: #222
+$background-secondary: #000
+// $background-gradient: linear-gradient(165deg, $background-primary 65%, $background-secondary 100%)
+
+$font-primary: $white
+$font-secondary: $primary
+$font-inverted: $black
+$font-link: $secondary
+
+// Default text
+*
+ font-family: "Karla", sans-serif
+ color: $font-primary
+ transition-duration: 0.1s
+
+::selection
+ background: $primary
+ color: $black
+
+// Components
+$navbar-height: 4rem
+
+body
+ background: $background-primary !important
+ margin: 0;
+// background: $background-primary !important
+// background: -moz-linear-gradient(165deg, $background-primary 65%, $background-secondary 100%) !important
+// background: -webkit-linear-gradient(165deg, $background-primary 65%, $background-secondary 100%) !important
+// background: linear-gradient(165deg, $background-primary 65%, $background-secondary 100%) !important
+// background-attachment: fixed !important
+
+.hidden
+ display: none
+
+.form-group
+ label
+ color: $font-secondary
+ margin-bottom: -0.3em
+ margin-left: 0.3em
+ input
+ background-color: $background-primary !important
+ color: $font-primary !important
+
+a
+ color: $font-link !important
+
+// .custom-checkbox
+// label
+// color: $font-primary !important
+
+// a.btn-secondary, a.btn-primary
+// color: $font-primary !important
+
+// #title
+// font-weight: bold
+
+// .breadcrumb
+// background-color: #FFF0 !important
+
+// .breadcrumb-item + .breadcrumb-item::before
+// content: ">" !important
+
+// .breadcrumb-item.active
+// span
+// color: $font-secondary !important
+
+// .modal-content
+// background-color: $background-primary
+
+// header.b-calendar-grid-caption, .b-calendar-grid-weekdays *, header.b-calendar-header *
+// color: $font-inverted !important
+
+// .b-form-datepicker
+// background-color: $background-primary !important
+
+// .b-form-datepicker label:not(.text-muted)
+// color: $font-primary !important
+
+// .modal-header button.close
+// color: $font-primary !important
+
+// input:disabled
+// color: $font-secondary !important
+// border-color: $grey !important
diff --git a/juggl-vue/src/views/Home.vue b/juggl-vue/src/views/Home.vue
new file mode 100644
index 0000000..2301d84
--- /dev/null
+++ b/juggl-vue/src/views/Home.vue
@@ -0,0 +1,21 @@
+
+
+ Hey there
+ Go to login
+
+
+
+
+
+
\ No newline at end of file
diff --git a/juggl-vue/src/views/Login.vue b/juggl-vue/src/views/Login.vue
new file mode 100644
index 0000000..aee528e
--- /dev/null
+++ b/juggl-vue/src/views/Login.vue
@@ -0,0 +1,20 @@
+
+
+
+ Go to home
+
+
+
+
\ No newline at end of file