Basic site working

This commit is contained in:
Maximilian Giller 2021-01-01 16:35:35 +01:00
parent 1be0761358
commit 045eb7ecc9
16 changed files with 494 additions and 256 deletions

View file

@ -12,7 +12,7 @@
"bootstrap": "^4.5.3", "bootstrap": "^4.5.3",
"bootstrap-vue": "^2.21.1", "bootstrap-vue": "^2.21.1",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"vue": "^2.6.11", "vue": "^2.6.12",
"vue-router": "^3.2.0", "vue-router": "^3.2.0",
"vuex": "^3.4.0" "vuex": "^3.4.0"
}, },

View file

@ -4,7 +4,7 @@
<b-img <b-img
:src="require('../../assets/logo.png')" :src="require('../../assets/logo.png')"
alt="Juggl" alt="Juggl"
:height="heightSize" :width="widthSize"
:center="center" :center="center"
/> />
</b-link> </b-link>
@ -25,13 +25,13 @@ export default {
} }
}, },
computed: { computed: {
heightSize: function() { widthSize: function() {
let sizes = { let sizes = {
mini: "35px", mini: "35px",
normal: "64px",
tiny: "80px", tiny: "80px",
smaller: "110px", smaller: "110px",
small: "150px", small: "150px",
normal: "175px",
medium: "300px", medium: "300px",
large: "450px", large: "450px",
big: "600px", big: "600px",

View file

@ -0,0 +1,22 @@
<template>
<b-dropdown id="dropdown" :text=this.username variant="outline-danger" right>
<b-dropdown-item to="/logout">Log out</b-dropdown-item>
</b-dropdown>
</template>
<script>
import store from "@/store";
export default {
name: "BaseUserDropdown",
computed: {
username: () => store.getters.user.name,
},
};
</script>
<style lang="sass" scoped>
#dropdown
*
line-height: 1.5
</style>

View file

@ -1,10 +1,9 @@
<template> <template>
<b-form @submit="submitForm"> <b-form @submit="submitForm">
<b-form-group id="email-group" label-for="email"> <b-form-group id="username-group" label-for="username">
<b-form-input <b-form-input
id="email" id="username"
v-model="form.user_id" v-model="form.user_id"
type="number"
required required
placeholder="User ID" placeholder="User ID"
trim trim
@ -24,7 +23,8 @@
<b-form-invalid-feedback :state="!failed"> <b-form-invalid-feedback :state="!failed">
Password or email invalid. Password or email invalid.
</b-form-invalid-feedback> </b-form-invalid-feedback>
<b-button variant="primary" type="submit" block> <b-button variant="primary" type="submit" block :disabled="working">
<b-spinner v-if="working" small/>
Log in Log in
</b-button> </b-button>
</b-form> </b-form>
@ -41,7 +41,8 @@ export default {
user_id: null, user_id: null,
api_key: null api_key: null
}, },
failed: false failed: false,
working: false
}; };
}, },
methods: { methods: {
@ -50,14 +51,16 @@ export default {
*/ */
submitForm: function(e) { submitForm: function(e) {
e.preventDefault(); e.preventDefault();
this.failed = false;
this.working = true;
// Try to login // Try to login
store.dispatch("login") store.dispatch("login", { userId: this.form.user_id, apiKey: this.form.api_key})
.login(this.form.user_id, this.form.api_key)
.then(r => { .then(r => {
if (r !== true) { if (r !== true) {
this.failed = true; this.failed = true;
return; this.working = false;
return false;
} }
// On success redirect to target or dashboard // On success redirect to target or dashboard
@ -68,6 +71,7 @@ export default {
.catch(e => { .catch(e => {
console.log(e); console.log(e);
this.failed = true; this.failed = true;
this.working = false;
}); });
return false; return false;

View file

@ -0,0 +1,74 @@
<template>
<div id="project-list">
<div v-for="project in projects" :key="project.project_id" @click="() => startProject(project.project_id)">
<h1>{{project.name}}</h1>
<p>{{getDurationTimestamp(project)}}</p>
<p>{{project.record_count}} records</p>
</div>
</div>
</template>
<script>
import store from "@/store"
export default {
name: "JugglProjectPanel",
props: {
projects: {
required: true
}
},
methods: {
getDurationTimestamp: (project) => {
var totalSeconds = project.duration;
// var days = Math.floor(totalSeconds / 86400);
var hours = Math.floor(totalSeconds / 3600); //Math.floor((totalSeconds - (days * 86400)) / 3600);
var minutes = Math.floor((totalSeconds - (hours * 3600)) / 60);
var seconds = totalSeconds - (hours * 3600) - (minutes * 60);
// if (days < 10) {days = "0"+days;}
if (hours < 10) {hours = "0"+hours;}
if (minutes < 10) {minutes = "0"+minutes;}
if (seconds < 10) {seconds = "0"+seconds;}
return hours+':'+minutes+':'+seconds;
},
startProject: function (id) {
store.dispatch("startRecord", id);
}
}
}
</script>
<style lang="sass" scoped>
@import '@/style/theme.sass'
#project-list
display: flex
flex-direction: row
flex-wrap: wrap
justify-content: center
align-content: flex-start
padding: 5px
> *
margin: 5px
border: 1px dashed $grey
border-radius: 5px
padding: 10px
&:hover
border-color: $primary !important
cursor: pointer
h1
font-weight: bold
font-size: 24pt
margin: 0px
padding: 0px
p
font-size: 10pt
color: $grey
margin: 0px
padding: 0px
</style>

View file

@ -0,0 +1,97 @@
<template>
<b-table :items="records" hover :busy="isLoading" :fields="fields">
<template #table-busy>
<div class="text-center">
<b-spinner></b-spinner>
</div>
</template>
<!-- Custom data -->
<template #cell(project)="data">
{{ getProject(data.item.project_id).name }}
</template>
<template #cell(start)="data">
{{ data.item.start_time }}
</template>
<template #cell(duration)="data">
{{ data.item.duration }}
</template>
<template #cell(stop)="data">
<b-button size="sm" @click="stopRecord(data.item.record_id)" variant="outline-success">
<b-icon class="icon-btn" icon="check" />
</b-button>
</template>
<template #cell(abort)="data">
<b-button size="sm" @click="() => abortRecord(data.item.record_id)" variant="outline-danger">
<b-icon class="icon-btn" icon="x" />
</b-button>
</template>
</b-table>
</template>
<script>
import store from "@/store";
export default {
name: "JugglRecordsList",
props: {
records: {
required: true,
},
},
data() {
return {
iconScale: 1.6,
fields: [
{
key: "project",
label: "Project",
},
{
key: "start",
label: "Start",
},
{
key: "duration",
label: "Duration",
},
{
key: "stop",
label: "Stop",
},
{
key: "abort",
label: "Abort",
},
],
};
},
computed: {
isLoading: function () {
return this.records === undefined;
},
},
methods: {
getProject: function (id) {
return store.getters.getProjectById(id);
},
stopRecord: function (id) {
store.dispatch("endRecord", id);
},
abortRecord: function (id) {
console.log(id);
},
},
};
</script>
<style lang="sass">
.icon-btn:hover
color: green
cursor: pointer
</styl>

View file

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<BaseLogo id="logo" size="smaller" center class="space-top" /> <BaseLogo id="logo" size="medium" center class="space-top" />
<BaseContainer :width="width" center class="space-bottom"> <BaseContainer :width="width" center class="space-bottom">
<BaseTitle v-if="title" center size="huge" class="centered"> <BaseTitle v-if="title" center size="huge" class="centered">
{{ title }} {{ title }}

View file

@ -4,23 +4,11 @@
<BaseContainer width="wide" center> <BaseContainer width="wide" center>
<ul id="header-container"> <ul id="header-container">
<li> <li>
<BaseLogo size="normal"/> <BaseLogo id="logo" size="normal"/>
</li>
<li class="right">
<BaseUserDropdown/>
</li> </li>
<!-- <li style="float: right">
<input
id="user-id"
class="public hidden"
type="text"
placeholder="User ID"
/>
<input
id="api-key"
class="public hidden"
type="password"
placeholder="API Key"
/>
<button id="auth-btn" onclick="handleAuthBtn()">Log In</button>
</li> -->
</ul> </ul>
</BaseContainer> </BaseContainer>
</header> </header>
@ -35,12 +23,14 @@
<script> <script>
import BaseContainer from "@/components/base/BaseContainer.vue"; import BaseContainer from "@/components/base/BaseContainer.vue";
import BaseLogo from "@/components/base/BaseLogo.vue"; import BaseLogo from "@/components/base/BaseLogo.vue";
import BaseUserDropdown from "@/components/base/BaseUserDropdown.vue";
export default { export default {
name: "LayoutNavbarPrivate", name: "LayoutNavbarPrivate",
components: { components: {
BaseContainer, BaseContainer,
BaseLogo BaseLogo,
BaseUserDropdown
} }
}; };
</script> </script>
@ -62,12 +52,11 @@ header
list-style: none list-style: none
line-height: navbar-height line-height: navbar-height
img .right
margin-top: 0.5rem float: right
height: 3rem
width: auto
* *
line-height: $navbar-height
vertical-align: baseline vertical-align: baseline
display: inline-block display: inline-block

View file

@ -1,21 +1,34 @@
import Vue from "vue"; import Vue from "vue";
import VueRouter from "vue-router"; import VueRouter from "vue-router";
import store from "../store";
import Login from "../views/Login.vue"; import Login from "../views/Login.vue";
import Home from "../views/Home.vue"; import Home from "../views/Home.vue";
Vue.use(VueRouter); Vue.use(VueRouter);
const routes = [ const routes = [
{
path: "/",
redirect: "/home"
},
{ {
path: "/login", path: "/login",
name: "Login", name: "Login",
component: Login, component: Login,
}, },
{ {
path: "/", path: "/home",
name: "Home", name: "Home",
component: Home, component: Home,
// beforeEnter: requireAuth, beforeEnter: requireAuth,
},
{
path: "/logout",
name: "Logout",
beforeEnter: (to, from, next) => {
store.dispatch("logout");
next("/");
},
}, },
]; ];
@ -28,16 +41,15 @@ const router = new VueRouter({
/** /**
* Checks authentication before proceeding * Checks authentication before proceeding
*/ */
// TODO: Authentication required in router function requireAuth(to, from, next) {
// function requireAuth(to, from, next) { if (!store.getters.isLoggedIn) {
// if (!userService.isLoggedIn()) { next({
// next({ path: "/login",
// path: "/login", query: { redirect: to.fullPath },
// query: { redirect: to.fullPath }, });
// }); } else {
// } else { next();
// next(); }
// } }
// }
export default router; export default router;

View file

@ -1,5 +1,5 @@
import axios from "axios"; import axios from "axios";
import { store } from "@/store"; import store from "@/store";
/** /**
* A wrapper for the used fetch API, currently axios. * A wrapper for the used fetch API, currently axios.
@ -15,11 +15,19 @@ export const apiService = {
}, },
post(resource, json, options) { post(resource, json, options) {
return this.getApi().post(resource, json, options); return this.getApi().post(
resource,
{ ...this.getDefaultJson(), ...json },
options
);
}, },
put(resource, json, options) { put(resource, json, options) {
return this.getApi().put(resource, json, options); return this.getApi().put(
resource,
{ ...this.getDefaultJson(), ...json },
options
);
}, },
/** /**
@ -32,4 +40,10 @@ export const apiService = {
return axios.create(options); return axios.create(options);
}, },
getDefaultJson() {
return {
user_id: store.getters.auth.userId,
api_key: store.getters.auth.apiKey,
};
},
}; };

View file

@ -1,5 +1,3 @@
// TODO: Do I need this file?
/** /**
* Contains a bunch of helper functions. * Contains a bunch of helper functions.
*/ */

View file

@ -11,212 +11,88 @@ export const jugglService = {
* @returns A promise * @returns A promise
*/ */
getUser() { getUser() {
return apiService.post("/getUser.php"); return apiService.post("/getUser.php").then((r) => {
return {
data: r.data,
msg: "",
};
});
}, },
getProjects() { getProjects() {
return apiService.post("/getProjects.php"); return apiService.post("/getProjects.php").then((r) => {
return {
data: r.data,
msg: "",
};
});
},
getRecord(recordId) {
return apiService
.post("/getRecord.php", {
record_id: recordId,
})
.then((r) => {
return {
data: processRecords(r.data),
msg: "",
};
});
}, },
getRecords() { getRecords() {
return apiService.post("/getRecords.php"); return apiService.post("/getRecords.php").then((r) => {
return {
data: processRecords(r.data),
msg: "",
};
});
}, },
getRunningRecords() { getRunningRecords() {
return apiService.post("/getRunningRecords.php"); return apiService.post("/getRunningRecords.php").then((r) => {
return {
data: processRecords(r.data),
msg: "",
};
});
}, },
startRecord(projectId, startTime = null) { startRecord(projectId, startTime = null) {
if (startTime == null) startTime = new Date(); if (startTime == null) startTime = new Date();
return apiService.post("/startRecord.php", { return apiService
.post("/startRecord.php", {
project_id: projectId, project_id: projectId,
start_time: helperService.dateToString(startTime), 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) => { .then((r) => {
return { success: true, msg: r.data.msg }; return {
}) data: r.data,
.catch((r) => { msg: "",
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;
}); });
}, },
/** endRecord(recordId, endTime = null) {
* Returns the jwt. if (endTime == null) endTime = new Date();
*/ return apiService
getToken() { .post("/endRecord.php", {
return localStorage.token; record_id: recordId,
}, end_time: helperService.dateToString(endTime),
})
/** .then((r) => {
* Removes the stored jwt and therefor prevents access to any page that requires authentication. return {
*/ data: r.data,
logout() { msg: "",
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;
}, },
}; };
function processRecords(data) {
Object.values(data.records).forEach(rec => {
rec.running = rec.end_time === null;
});
return data;
}

View file

@ -7,10 +7,10 @@ Vue.use(Vuex);
export default new Vuex.Store({ export default new Vuex.Store({
state: { state: {
apiUrl: "https://juggl.giller.dev/api", apiUrl: "https://juggl.giller.dev/api",
apiKey: undefined, projects: {},
records: {},
user: undefined, user: undefined,
projects: [], auth: undefined,
records: [],
}, },
mutations: { mutations: {
setKey(state, key) { setKey(state, key) {
@ -22,35 +22,145 @@ export default new Vuex.Store({
setRecords(state, records) { setRecords(state, records) {
state.records = records; state.records = records;
}, },
setUser(state, user) {
state.user = user;
},
logout(state) {
state.auth = undefined;
// TODO: Doesn't work apparently
localStorage.removeItem("apiKey");
localStorage.removeItem("userId");
},
login(state, { apiKey, userId }) {
state.auth = { apiKey: apiKey, userId: userId };
localStorage.setItem("apiKey", apiKey);
localStorage.setItem("userId", userId);
},
}, },
getters: { getters: {
runningRecords: (state) => { runningRecords: (state) => {
return state.records.filter((record) => record.running); return Object.values(state.records).filter((record) => record.running);
}, },
finishedRecords: (state) => { finishedRecords: (state) => {
return state.records.filter((record) => !record.running); return Object.values(state.records).filter(
(record) => !record.running
);
}, },
apiKey: (state) => state.apiKey, auth: (state) => state.auth,
user: (state) => state.user,
apiUrl: (state) => state.apiUrl, apiUrl: (state) => state.apiUrl,
isLoggedIn: (state) => !!state.apiKey, user: (state) => state.user,
isLoggedIn: (state) => !!state.auth,
records: (state) => state.records, records: (state) => state.records,
projects: (state) => state.projects, projects: (state) => state.projects,
projectIds: (state) => {
var projectIds = [];
Object.values(state.projects).forEach((project) => {
projectIds.push(project.project_id);
});
return projectIds;
},
runningProjectIds: (state, getters) => {
var runningProjectIds = [];
Object.values(getters.runningRecords).forEach((record) => {
var projectId = record.project_id;
if (runningProjectIds.includes(runningProjectIds) === false) {
runningProjectIds.push(projectId);
}
});
return runningProjectIds;
},
finishedProjectIds: (state, getters) => {
var runningProjectIds = getters.runningProjectIds;
return getters.projectIds.filter(
(id) => !runningProjectIds.includes(id)
);
},
finishedProjects: (state, getters) => {
var ids = getters.finishedProjectIds;
return Object.values(state.projects).filter((project) =>
ids.includes(project.project_id)
);
},
runningProjects: (state, getters) => {
var ids = getters.runningProjectIds;
return Object.values(state.projects).filter((project) =>
ids.includes(project.project_id)
);
},
getProjectById: (state, getters) => (id) => { getProjectById: (state, getters) => (id) => {
return getters.projects.find( return Object.values(getters.projects).find(
(project) => project.project_id === id (project) => project.project_id === id
); );
}, },
getRecordById: (state, getters) => (id) => { getRecordById: (state, getters) => (id) => {
return getters.records.find((record) => record.record_id === id); return Object.values(getters.records).find((record) => record.record_id === id);
}, },
}, },
actions: { actions: {
loadProjects({ commit }) { loadProjects({ commit }) {
commit("setProjects"); jugglService.getProjects().then((r) => {
commit("setProjects", r.data.projects);
});
}, },
login({ commit, userId, apiKey }) { loadAllRecords({ commit }) {
return jugglService.login(); jugglService.getRecords().then((r) => {
commit("setRecords", r.data.records);
});
}, },
loadRunningRecords({ commit, getters }) {
jugglService.getRunningRecords().then((r) => {
var allRecords = {
...getters.finishedRecords,
...r.data.records,
};
commit("setRecords", allRecords);
});
},
login({ commit, getters }, { userId, apiKey }) {
// Is already logged in?
if (getters.isLoggedIn) {
this.dispatch("logout");
}
commit("login", { apiKey: apiKey, userId: userId });
return jugglService
.getUser()
.catch(() => {
this.dispatch("logout");
return false;
})
.then((r) => {
commit("setUser", r.data.users[0]);
return true;
});
},
logout({ commit }) {
commit("setUser", undefined);
commit("logout");
},
endRecord(context, recordId) {
return jugglService
.endRecord(recordId)
.catch(() => {
return false;
})
.then(() => {
this.dispatch("loadRunningRecords");
this.dispatch("loadProjects");
return true;
});
},
startRecord(context, projectId) {
return jugglService
.startRecord(projectId)
.catch(() => {
return false;
})
.then(() => {
this.dispatch("loadRunningRecords");
return true;
});
}
}, },
}); });

View file

@ -6,6 +6,7 @@ $black: #000
$white: #fff $white: #fff
$primary: #F00 $primary: #F00
$secondary: $grey $secondary: $grey
$red: $primary
$background-primary: #222 $background-primary: #222
$background-secondary: #000 $background-secondary: #000
@ -53,6 +54,23 @@ body
a a
color: $font-link !important color: $font-link !important
.b-dropdown, .dropdown
border-radius: 0
background: #0000
ul.dropdown-menu
background-color: $background-primary
border: 1px solid $primary
color: $primary
*:hover
color: $white !important
background: darken($primary, 20)
*:active
color: $white !important
background: $primary
// .custom-checkbox // .custom-checkbox
// label // label
// color: $font-primary !important // color: $font-primary !important

View file

@ -1,21 +1,46 @@
<template> <template>
<LayoutNavbarPrivate> <LayoutNavbarPrivate>
Hey there <div v-if="runningRecords.length > 0">
<b-link to="/login">Go to login</b-link> <h2 class="center">Tracking</h2>
<JugglRecordsList :records="runningRecords"/>
</div>
<div v-if="finishedProjects.length > 0">
<h2 class="center">Projects</h2>
<JugglProjectsPanel :projects="finishedProjects"/>
</div>
</LayoutNavbarPrivate> </LayoutNavbarPrivate>
</template> </template>
<script> <script>
import LayoutNavbarPrivate from "@/components/layout/LayoutNavbarPrivate"; import LayoutNavbarPrivate from "@/components/layout/LayoutNavbarPrivate";
import JugglProjectsPanel from "@/components/juggl/JugglProjectsPanel";
import JugglRecordsList from "@/components/juggl/JugglRecordsList";
import store from "@/store";
export default { export default {
name: "Home", name: "Home",
components: { components: {
LayoutNavbarPrivate LayoutNavbarPrivate,
JugglProjectsPanel,
JugglRecordsList
},
computed: {
finishedProjects: () => {
return store.getters.finishedProjects;
},
runningRecords: () => {
return store.getters.runningRecords;
}
},
created: () => {
store.dispatch("loadProjects");
store.dispatch("loadRunningRecords");
} }
} }
</script> </script>
<style> <style lang="sass">
.center
text-align: center
font-weight: bold
</style> </style>

View file

@ -1,7 +1,6 @@
<template> <template>
<LayoutMinimal title="Login" width="slimmer"> <LayoutMinimal title="Login" width="slimmer">
<FormLogin/> <FormLogin/>
<b-link to="/">Go to home</b-link>
</LayoutMinimal> </LayoutMinimal>
</template> </template>