WIP...
This commit is contained in:
parent
dc3fb83819
commit
3e2ac648c2
@ -1,77 +1,140 @@
|
|||||||
[resolvers.deleteTask]
|
[resolvers.createTask]
|
||||||
operation_name = "deleteTask"
|
operation_name = "createTask"
|
||||||
|
|
||||||
[resolvers.deleteTask.resolver]
|
[resolvers.createTask.resolver]
|
||||||
command_type = "SQLDelete"
|
command_type = "SQLInsert"
|
||||||
|
|
||||||
|
[resolvers.createTask.resolver.command]
|
||||||
|
table = "Task"
|
||||||
columns = []
|
columns = []
|
||||||
tables = []
|
where_clauses = []
|
||||||
where_clauses = [["Task", "id", "= '$id'"]]
|
aggregate_result = true
|
||||||
join_clauses = []
|
|
||||||
modify_table = ["Task", ""]
|
[[resolvers.createTask.resolver.command.modify_values]]
|
||||||
modify_values = []
|
column = "title"
|
||||||
aggregate_final_json_result = true
|
value = "'$title'"
|
||||||
|
|
||||||
|
[[resolvers.createTask.resolver.command.modify_values]]
|
||||||
|
column = "description"
|
||||||
|
value = "'$description'"
|
||||||
|
|
||||||
|
[[resolvers.createTask.resolver.command.modify_values]]
|
||||||
|
column = "completed"
|
||||||
|
value = "$completed"
|
||||||
|
|
||||||
|
[[resolvers.createTask.resolver.command.modify_values]]
|
||||||
|
column = "list_id"
|
||||||
|
value = "'$list.$id'"
|
||||||
|
|
||||||
|
[[resolvers.createTask.resolver.command.modify_values]]
|
||||||
|
column = "user_username"
|
||||||
|
value = "'$user.$username'"
|
||||||
|
|
||||||
[resolvers.updateTask]
|
[resolvers.updateTask]
|
||||||
operation_name = "updateTask"
|
operation_name = "updateTask"
|
||||||
|
|
||||||
[resolvers.updateTask.resolver]
|
[resolvers.updateTask.resolver]
|
||||||
command_type = "SQLUpdate"
|
command_type = "SQLUpdate"
|
||||||
|
|
||||||
|
[resolvers.updateTask.resolver.command]
|
||||||
|
table = "Task"
|
||||||
columns = []
|
columns = []
|
||||||
tables = []
|
aggregate_result = true
|
||||||
where_clauses = [["Task", "id", "= '$id'"]]
|
|
||||||
join_clauses = []
|
|
||||||
modify_table = ["Task", ""]
|
|
||||||
modify_values = [["title", "'$title'"], ["description", "'$description'"], ["completed", "$completed"], ["list_id", "'$list.$id'"]]
|
|
||||||
aggregate_final_json_result = true
|
|
||||||
|
|
||||||
[resolvers.createUser]
|
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||||
operation_name = "createUser"
|
column = "title"
|
||||||
|
value = "'$title'"
|
||||||
|
|
||||||
[resolvers.createUser.resolver]
|
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||||
command_type = "SQLInsert"
|
column = "description"
|
||||||
columns = []
|
value = "'$description'"
|
||||||
tables = []
|
|
||||||
where_clauses = []
|
|
||||||
join_clauses = []
|
|
||||||
modify_table = ["User", ""]
|
|
||||||
modify_values = [["username", "'$username'"], ["name", "'$name'"]]
|
|
||||||
aggregate_final_json_result = true
|
|
||||||
|
|
||||||
[resolvers.createList]
|
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||||
operation_name = "createList"
|
column = "completed"
|
||||||
|
value = "$completed"
|
||||||
|
|
||||||
[resolvers.createList.resolver]
|
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||||
command_type = "SQLInsert"
|
column = "list_id"
|
||||||
columns = []
|
value = "'$list.$id'"
|
||||||
tables = []
|
|
||||||
where_clauses = []
|
|
||||||
join_clauses = []
|
|
||||||
modify_table = ["List", ""]
|
|
||||||
modify_values = [["title", "'$title'"], ["user_username", "'$user.$username'"]]
|
|
||||||
aggregate_final_json_result = true
|
|
||||||
|
|
||||||
[resolvers.createTask]
|
[[resolvers.updateTask.resolver.command.where_clauses]]
|
||||||
operation_name = "createTask"
|
table = "Task"
|
||||||
|
column = "id"
|
||||||
[resolvers.createTask.resolver]
|
condition_str = "= '$id'"
|
||||||
command_type = "SQLInsert"
|
index = ""
|
||||||
columns = []
|
|
||||||
tables = []
|
|
||||||
where_clauses = []
|
|
||||||
join_clauses = []
|
|
||||||
modify_table = ["Task", ""]
|
|
||||||
modify_values = [["title", "'$title'"], ["description", "'$description'"], ["completed", "$completed"], ["list_id", "'$list.$id'"], ["user_username", "'$user.$username'"]]
|
|
||||||
aggregate_final_json_result = true
|
|
||||||
|
|
||||||
[resolvers.getUser]
|
[resolvers.getUser]
|
||||||
operation_name = "getUser"
|
operation_name = "getUser"
|
||||||
|
|
||||||
[resolvers.getUser.resolver]
|
[resolvers.getUser.resolver]
|
||||||
command_type = "SQLSelect"
|
command_type = "SQLSelect"
|
||||||
|
|
||||||
|
[resolvers.getUser.resolver.command]
|
||||||
|
table = "User"
|
||||||
columns = []
|
columns = []
|
||||||
tables = [["User", ""]]
|
|
||||||
where_clauses = [["User", "username", "= '$username'"]]
|
|
||||||
join_clauses = []
|
|
||||||
modify_table = ["", ""]
|
|
||||||
modify_values = []
|
modify_values = []
|
||||||
aggregate_final_json_result = true
|
aggregate_result = true
|
||||||
|
|
||||||
|
[[resolvers.getUser.resolver.command.where_clauses]]
|
||||||
|
table = "User"
|
||||||
|
column = "username"
|
||||||
|
condition_str = "= '$username'"
|
||||||
|
index = ""
|
||||||
|
|
||||||
|
[resolvers.deleteTask]
|
||||||
|
operation_name = "deleteTask"
|
||||||
|
|
||||||
|
[resolvers.deleteTask.resolver]
|
||||||
|
command_type = "SQLDelete"
|
||||||
|
|
||||||
|
[resolvers.deleteTask.resolver.command]
|
||||||
|
table = "Task"
|
||||||
|
columns = []
|
||||||
|
modify_values = []
|
||||||
|
aggregate_result = true
|
||||||
|
|
||||||
|
[[resolvers.deleteTask.resolver.command.where_clauses]]
|
||||||
|
table = "Task"
|
||||||
|
column = "id"
|
||||||
|
condition_str = "= '$id'"
|
||||||
|
index = ""
|
||||||
|
|
||||||
|
[resolvers.createUser]
|
||||||
|
operation_name = "createUser"
|
||||||
|
|
||||||
|
[resolvers.createUser.resolver]
|
||||||
|
command_type = "SQLInsert"
|
||||||
|
|
||||||
|
[resolvers.createUser.resolver.command]
|
||||||
|
table = "User"
|
||||||
|
columns = []
|
||||||
|
where_clauses = []
|
||||||
|
aggregate_result = true
|
||||||
|
|
||||||
|
[[resolvers.createUser.resolver.command.modify_values]]
|
||||||
|
column = "username"
|
||||||
|
value = "'$username'"
|
||||||
|
|
||||||
|
[[resolvers.createUser.resolver.command.modify_values]]
|
||||||
|
column = "name"
|
||||||
|
value = "'$name'"
|
||||||
|
|
||||||
|
[resolvers.createList]
|
||||||
|
operation_name = "createList"
|
||||||
|
|
||||||
|
[resolvers.createList.resolver]
|
||||||
|
command_type = "SQLInsert"
|
||||||
|
|
||||||
|
[resolvers.createList.resolver.command]
|
||||||
|
table = "List"
|
||||||
|
columns = []
|
||||||
|
where_clauses = []
|
||||||
|
aggregate_result = true
|
||||||
|
|
||||||
|
[[resolvers.createList.resolver.command.modify_values]]
|
||||||
|
column = "title"
|
||||||
|
value = "'$title'"
|
||||||
|
|
||||||
|
[[resolvers.createList.resolver.command.modify_values]]
|
||||||
|
column = "user_username"
|
||||||
|
value = "'$user.$username'"
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
52
package-lock.json
generated
52
package-lock.json
generated
@ -5,8 +5,12 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"name": "todo",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.6",
|
||||||
|
"bootstrap": "^5.3.0-alpha1",
|
||||||
|
"bootstrap-icons": "^1.10.3",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-router": "^4.1.6"
|
"vue-router": "^4.1.6"
|
||||||
},
|
},
|
||||||
@ -379,6 +383,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@popperjs/core": {
|
||||||
|
"version": "2.11.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
|
||||||
|
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/popperjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
|
||||||
@ -521,6 +534,29 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bootstrap": {
|
||||||
|
"version": "5.3.0-alpha1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0-alpha1.tgz",
|
||||||
|
"integrity": "sha512-ABZpKK4ObS3kKlIqH+ZVDqoy5t/bhFG0oHTAzByUdon7YIom0lpCeTqRniDzJmbtcWkNe800VVPBiJgxSYTYew==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/twbs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/bootstrap"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@popperjs/core": "^2.11.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bootstrap-icons": {
|
||||||
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-7Qvj0j0idEm/DdX9Q0CpxAnJYqBCFCiUI6qzSPYfERMcokVuV9Mdm/AJiVZI8+Gawe4h/l6zFcOzvV7oXCZArw=="
|
||||||
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||||
@ -1141,6 +1177,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"@popperjs/core": {
|
||||||
|
"version": "2.11.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
|
||||||
|
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw=="
|
||||||
|
},
|
||||||
"@vitejs/plugin-vue": {
|
"@vitejs/plugin-vue": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
|
||||||
@ -1268,6 +1309,17 @@
|
|||||||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"bootstrap": {
|
||||||
|
"version": "5.3.0-alpha1",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0-alpha1.tgz",
|
||||||
|
"integrity": "sha512-ABZpKK4ObS3kKlIqH+ZVDqoy5t/bhFG0oHTAzByUdon7YIom0lpCeTqRniDzJmbtcWkNe800VVPBiJgxSYTYew==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
|
"bootstrap-icons": {
|
||||||
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-7Qvj0j0idEm/DdX9Q0CpxAnJYqBCFCiUI6qzSPYfERMcokVuV9Mdm/AJiVZI8+Gawe4h/l6zFcOzvV7oXCZArw=="
|
||||||
|
},
|
||||||
"braces": {
|
"braces": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||||
|
@ -8,6 +8,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@popperjs/core": "^2.11.6",
|
||||||
|
"bootstrap": "^5.3.0-alpha1",
|
||||||
|
"bootstrap-icons": "^1.10.3",
|
||||||
"vue": "^3.2.47",
|
"vue": "^3.2.47",
|
||||||
"vue-router": "^4.1.6"
|
"vue-router": "^4.1.6"
|
||||||
},
|
},
|
||||||
|
127
src/App.vue
127
src/App.vue
@ -3,7 +3,6 @@ import { RouterLink, RouterView } from 'vue-router'
|
|||||||
import HelloWorld from './components/Welcome.vue'
|
import HelloWorld from './components/Welcome.vue'
|
||||||
import { store } from './store.js'
|
import { store } from './store.js'
|
||||||
import {clearSession} from "./store.js";
|
import {clearSession} from "./store.js";
|
||||||
import FatalError from "./components/FatalError.vue";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout the user.
|
* Logout the user.
|
||||||
@ -13,98 +12,64 @@ function logOut() {
|
|||||||
location.href = "/";
|
location.href = "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove error alert from the screen.
|
||||||
|
*/
|
||||||
|
function dismissError() {
|
||||||
|
store.fatalError = "";
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<header class="sticky-top">
|
||||||
<img alt="basebox logo" class="logo" src="@/assets/img/basebox_logo.svg" />
|
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="/">
|
||||||
|
<img alt="basebox logo" class="logo" src="@/assets/img/basebox_logo.svg" />
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div class="wrapper">
|
<!-- navigation items are shown only if logged in -->
|
||||||
<HelloWorld />
|
<div v-if="store.session.token" class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
<nav>
|
<li class="nav-item">
|
||||||
<RouterLink to="/">Home</RouterLink>
|
<RouterLink to="/about" class="nav-link" active-class="active">About</RouterLink>
|
||||||
<RouterLink to="/about">About</RouterLink>
|
</li>
|
||||||
<a v-if="store.session" href="" @click="logOut()">Log Out</a>
|
<li class="nav-item">
|
||||||
</nav>
|
<RouterLink to="/lists" class="nav-link" active-class="active">Lists</RouterLink>
|
||||||
|
</li>
|
||||||
<FatalError v-if="store.fatalError" :error-msg="store.fatalError"/>
|
</ul>
|
||||||
|
<ul class="navbar-nav mb-2 mb-lg-0">
|
||||||
|
<li class="navbar-text">Hello, {{ store.session.username }}!</li>
|
||||||
</div>
|
<li class="nav-item ms-2">
|
||||||
|
<button class="nav-link btn btn-sm btn-secondary" @click="logOut()">Logout</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<RouterView />
|
<main class="container">
|
||||||
|
<div class="alert alert-dismissible alert-danger mb-3 fade show" v-if="store.fatalError">
|
||||||
|
<p><strong>An error occurred:</strong></p>
|
||||||
|
<p v-html="store.fatalError"></p>
|
||||||
|
<button type="button" class="btn-close" @click="dismissError()" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
header {
|
|
||||||
line-height: 1.5;
|
|
||||||
max-height: 100vh;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto 2rem;
|
height: 32px;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
@media(max-width: 1024px) {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
header .logo {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 auto 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .wrapper {
|
|
||||||
display: flex;
|
|
||||||
place-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,89 +0,0 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
|
||||||
:root {
|
|
||||||
--vt-c-white: #ffffff;
|
|
||||||
--vt-c-white-soft: #f8f8f8;
|
|
||||||
--vt-c-white-mute: #f2f2f2;
|
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
|
||||||
--vt-c-black-soft: #222222;
|
|
||||||
--vt-c-black-mute: #282828;
|
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
|
||||||
|
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
|
||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-white);
|
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
|
||||||
--color-text: var(--vt-c-text-light-1);
|
|
||||||
|
|
||||||
--section-gap: 160px;
|
|
||||||
|
|
||||||
--color-input-background: #fff;
|
|
||||||
--color-input: rgba(0,0,0,.85);
|
|
||||||
--color-input-border-focus: rgba(0,0,0,.2);
|
|
||||||
--color-error-text: darkred;
|
|
||||||
--color-item-background: white;
|
|
||||||
--color-item-text: rgb(33, 53, 71);
|
|
||||||
--item-box-shadow: 0 3px 12px rgba(0, 0, 0, .07), 0 1px 4px rgba(0, 0, 0, .07);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-black);
|
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
|
||||||
--color-input-background: rgba(255,255,255,.05);
|
|
||||||
--color-input: rgba(255,255,255,.8);
|
|
||||||
--color-input-border-focus: rgba(0,0,0,.4);
|
|
||||||
--color-error-text: red;
|
|
||||||
--color-item-background: #242424;
|
|
||||||
--color-item-text: white;
|
|
||||||
--item-box-shadow: 0 3px 12px rgba(0, 0, 0, .07), 0 1px 4px rgba(0, 0, 0, .07);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
position: relative;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
color: var(--color-text);
|
|
||||||
background: var(--color-background);
|
|
||||||
transition: color 0.5s, background-color 0.5s;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
|
||||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
Binary file not shown.
Before Width: | Height: | Size: 2.6 KiB |
@ -1,113 +1,74 @@
|
|||||||
@import './base.css';
|
main {
|
||||||
|
padding-top: 20px;
|
||||||
#app {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul{
|
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
.primary {
|
|
||||||
text-decoration: none;
|
|
||||||
color: #3AB2FF;
|
|
||||||
transition: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
a:hover {
|
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
padding: 0 4rem 0 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin: 25px 0;
|
|
||||||
label {
|
label {
|
||||||
text-transform: uppercase;
|
white-space: nowrap;
|
||||||
font-size: .8rem;
|
}
|
||||||
font-weight: 700;
|
}
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: .3em;
|
||||||
|
padding: 5px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--bs-tertiary-bg);
|
||||||
|
box-shadow: var(--item-box-shadow);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: .3em;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
&.completed {
|
||||||
|
border: 3px solid #41ab57;
|
||||||
}
|
}
|
||||||
input {
|
input {
|
||||||
border: 1px solid var(--color-border);
|
border: none;
|
||||||
padding: 6px 8px;
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
display: block;
|
outline: none;
|
||||||
border-radius: 2px;
|
margin-bottom: 15px;
|
||||||
width: 100%;
|
color: var(--color-item-text);
|
||||||
background-color: var(--color-input-background);
|
background: transparent;
|
||||||
color: var(--color-input);
|
|
||||||
appearance: none;
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: var(--color-input-border-focus);
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.item-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-buttons {
|
.item-v-container {
|
||||||
margin-top: 25px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.item-meta {
|
||||||
padding: 5px 20px;
|
padding: 5px 5px 5px 25px;
|
||||||
border: 1px solid var(--color-border);
|
display: flex;
|
||||||
border-radius: 2px;
|
flex-direction: column;
|
||||||
cursor: pointer;
|
align-items: end;
|
||||||
&.btn-primary {
|
}
|
||||||
background-color: #1890ff;
|
|
||||||
color: white;
|
@keyframes bounce-in {
|
||||||
border-color: #1890ff;
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
&.btn-large {
|
100% {
|
||||||
font-size: 1.25rem;
|
transform: scale(1);
|
||||||
padding: 6px 25px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-container {
|
.list-enter-active {
|
||||||
margin: 30px 0;
|
animation: bounce-in 0.4s;
|
||||||
|
}
|
||||||
|
.list-leave-active {
|
||||||
|
animation: bounce-in 0.4s reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
form .error {
|
|
||||||
color: var(--color-error-text);
|
|
||||||
margin: 25px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
select.form-control:not([multiple]) {
|
|
||||||
cursor: pointer;
|
|
||||||
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAYAAABiDJ37AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAI9JREFUeNpiZACCyMjIBCDVD8SOy5cvv8BAAgDqNQBS+4G4EKh3ASPUsPlQ+Q+kGIpkmABUKJEJ6jIYAEnshyok1TAQ6AcZ6Ah1GdGG4jAM7DtGQgrQvU9ILSOxColVw0is7VA2QV8wkhA+DMQECSMJgc5AyDCcBhIwFG9aZSQxrRFM+IwkJGAGYnIRQIABACQuXCKovu2mAAAAAElFTkSuQmCC);
|
|
||||||
background-size: 1rem;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: calc(100% - 1rem) center;
|
|
||||||
padding: .5em 3em .5em 1em;
|
|
||||||
background-color: var(--color-background);
|
|
||||||
color: var(--color-text);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: .4rem;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
@ -1,57 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component that displays oauth/login related errors.
|
|
||||||
|
|
||||||
Part of the basebox sample Todo app.
|
|
||||||
https://basebox.tech
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<div id="error-message">
|
|
||||||
<h3>An error occurred...</h3>
|
|
||||||
|
|
||||||
<p>{{ errorMsg }}</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<a href="/" class="btn btn-primary">Start Over</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: "OAuthError",
|
|
||||||
props: {
|
|
||||||
errorMsg: {
|
|
||||||
type: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
#error-message {
|
|
||||||
h3 {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
position: absolute;
|
|
||||||
min-height: 100%;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid #ff5050;
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: 2rem;
|
|
||||||
background: #4a1c1c;
|
|
||||||
p {
|
|
||||||
margin: 30px 0;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
margin-top: 20px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
117
src/components/Lists.vue
Normal file
117
src/components/Lists.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<!--
|
||||||
|
Component for editing task lists.
|
||||||
|
|
||||||
|
Part of the basebox sample Todo app.
|
||||||
|
https://basebox.tech
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
Lists
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" @click="addItem()" class="btn btn-primary btn-large">New List</button>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<small>{{ gStore.lists.length }} lists found.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="list-container">
|
||||||
|
<transition-group name="list" tag="div">
|
||||||
|
<div class="list-item" v-for="list in gStore.lists" :key="list.id">
|
||||||
|
<div class="item-v-container">
|
||||||
|
<input class="item-title" type="text" @blur="saveList(list)" v-model="list.title">
|
||||||
|
</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#">Delete this list</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<span class="item-counter">
|
||||||
|
{{ listItemCount(list) }}
|
||||||
|
<i class="bi bi-card-checklist"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info" v-if="gStore.lists.length === 0">
|
||||||
|
<p>There are no lists, yet.</p>
|
||||||
|
<button type="button" @click="addItem()" class="btn btn-primary btn-large">Create your first list</button>
|
||||||
|
</div>
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {gqlQuery} from "../util/net";
|
||||||
|
import {store} from "../store";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Lists",
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a list to the database.
|
||||||
|
* @param list
|
||||||
|
*/
|
||||||
|
saveList(list)
|
||||||
|
{
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new list.
|
||||||
|
*/
|
||||||
|
addItem()
|
||||||
|
{
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return number of todo items in a list.
|
||||||
|
*/
|
||||||
|
listItemCount(list) {
|
||||||
|
return store.tasks.filter((item) => item.list.id === list.id).length;
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* Add global store to the context, so we can refer to it in the template.
|
||||||
|
*/
|
||||||
|
gStore() {
|
||||||
|
return store;
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
#list-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-counter {
|
||||||
|
font-size: .8em;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
padding: 3px 5px;
|
||||||
|
text-align: right;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
286
src/components/Todo.vue
Normal file
286
src/components/Todo.vue
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
<!--
|
||||||
|
Root component for the Todo app that is displayed if the user is logged in.
|
||||||
|
|
||||||
|
Part of the basebox sample Todo app.
|
||||||
|
https://basebox.tech
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
|
||||||
|
<h1>
|
||||||
|
ToDo List
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div id="filter-form" class="toolbar">
|
||||||
|
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="showCompleted">
|
||||||
|
Show completed
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button" @click="addItem()" class="ms-auto btn btn-primary btn-large">Add Item</button>
|
||||||
|
|
||||||
|
<div class="ms-auto">
|
||||||
|
<div class="d-flex">
|
||||||
|
<label class="col-form-label" for="list-selector">Select List: </label>
|
||||||
|
<div class="ms-2">
|
||||||
|
<select id="list-selector" class="form-select" v-model="currentList">
|
||||||
|
<option :value="0">All</option>
|
||||||
|
<option v-for="item in gStore.lists" :key="item.id" :value="item.id">{{ item.title }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="todo-container">
|
||||||
|
|
||||||
|
<transition-group name="list" tag="div">
|
||||||
|
<div :class="todoItemClass(task)" v-for="(task, i) in filteredItems" :id="'task-' + task.id" :key="task.id">
|
||||||
|
<div :class="todoCheckedClass(task)" @click="toggleCompleted(task)">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="item-v-container">
|
||||||
|
<input class="item-title" type="text" @blur="saveTask(task)" v-model="task.title">
|
||||||
|
<input class="todo-description" type="text" @blur="saveTask(task)" v-model="task.description">
|
||||||
|
</div>
|
||||||
|
<div class="item-meta">
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="#">Delete this item</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info" v-if="filteredItems.length === 0">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</transition-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {gqlQuery} from "../util/net";
|
||||||
|
import {store} from "../store";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Todo",
|
||||||
|
components: [],
|
||||||
|
|
||||||
|
/** Current state of the component */
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
/** whether to show completed items */
|
||||||
|
showCompleted: true,
|
||||||
|
/* the id of the list currently being shown */
|
||||||
|
currentList: 1,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a task to the database.
|
||||||
|
* @param task the task to save; this is an object as received from the broker.
|
||||||
|
*/
|
||||||
|
saveTask(task) {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an item to the current list.
|
||||||
|
*/
|
||||||
|
addItem() {
|
||||||
|
if (this.currentList === 0) {
|
||||||
|
window.alert("Please select a list at the top right before adding an item.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fake ID of new item */
|
||||||
|
const newItemId = store.tasks.length + 1;
|
||||||
|
|
||||||
|
store.tasks.push({
|
||||||
|
id: newItemId,
|
||||||
|
completed: false,
|
||||||
|
title: "Enter task here",
|
||||||
|
description: "",
|
||||||
|
list: {
|
||||||
|
id: this.currentList,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* wait a moment, then scroll list to the bottom */
|
||||||
|
setTimeout(function() {
|
||||||
|
document.getElementById("todo-container").scrollTo({
|
||||||
|
top: 100000,
|
||||||
|
behavior: "smooth"
|
||||||
|
});
|
||||||
|
/* select text in new item's title field */
|
||||||
|
const titleInput = document.querySelector(`#task-${newItemId} .item-title`);
|
||||||
|
titleInput.setSelectionRange(0, 1000);
|
||||||
|
titleInput.focus();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle completed state of a todo item.
|
||||||
|
* @param task the todo item to toggle.
|
||||||
|
*/
|
||||||
|
toggleCompleted(task) {
|
||||||
|
task.completed = !task.completed;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return class object for a todo checked symbol
|
||||||
|
*
|
||||||
|
* @param task the item
|
||||||
|
*/
|
||||||
|
todoCheckedClass(task) {
|
||||||
|
return {
|
||||||
|
"todo-checked": true,
|
||||||
|
"completed": task.completed,
|
||||||
|
"uncompleted": !task.completed,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return class object for a todo item
|
||||||
|
*
|
||||||
|
* @param task the item
|
||||||
|
*/
|
||||||
|
todoItemClass(task) {
|
||||||
|
return {
|
||||||
|
"list-item": true,
|
||||||
|
"completed": task.completed,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add global store to the context, so we can refer to it in the template.
|
||||||
|
*/
|
||||||
|
gStore() {
|
||||||
|
return store;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return array of tasks to show.
|
||||||
|
*/
|
||||||
|
filteredItems() {
|
||||||
|
return store.tasks.filter((item) => {
|
||||||
|
/* hide completed items if the option to show them is not set */
|
||||||
|
if (!this.showCompleted && item.completed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/* only return items of the current list */
|
||||||
|
if (this.currentList && item.list.id !== this.currentList) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mounted lifecycle hook.
|
||||||
|
*/
|
||||||
|
mounted() {
|
||||||
|
if (!store.session) {
|
||||||
|
console.error("TodoRoot component must not be loaded if user is not logged in.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load user info and his/her lists and todos */
|
||||||
|
gqlQuery(`query {
|
||||||
|
getUser(username: "${store.session.userName}") {
|
||||||
|
name
|
||||||
|
lists {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
}
|
||||||
|
tasks {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
completed
|
||||||
|
list {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`).then(rspJson => {
|
||||||
|
|
||||||
|
}).catch(err => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
#todo-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.todo-checked {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 20px;
|
||||||
|
min-height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 4px solid var(--bs-border-color);
|
||||||
|
background-size: 80%;
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
&.uncompleted {
|
||||||
|
&:hover {
|
||||||
|
background-image: url(../assets/img/check-gray.svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.completed {
|
||||||
|
border-color: #41ab57;
|
||||||
|
background-image: url(../assets/img/check-green.svg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons {
|
||||||
|
float:right;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
padding-left: .5em;
|
||||||
|
input {
|
||||||
|
transform: scale(150%);
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
@ -1,342 +0,0 @@
|
|||||||
<!--
|
|
||||||
Root component for the Todo app that is displayed if the user is logged in.
|
|
||||||
|
|
||||||
Part of the basebox sample Todo app.
|
|
||||||
https://basebox.tech
|
|
||||||
-->
|
|
||||||
<template>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
|
|
||||||
<h1>
|
|
||||||
ToDo List
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div id="filter-form">
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" v-model="showCompleted">
|
|
||||||
Show completed
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="list-selector">List: </label>
|
|
||||||
<select id="list-selector" class="form-control" v-model="currentList">
|
|
||||||
<option :value="0">All</option>
|
|
||||||
<option v-for="item in lists" :key="item.id" :value="item.id">{{ item.title }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="todo-container">
|
|
||||||
|
|
||||||
<transition-group name="list" tag="div">
|
|
||||||
<div :class="todoItemClass(task)" v-for="(task, i) in filteredItems" :id="'task-' + task.id" :key="task.id">
|
|
||||||
<div :class="todoCheckedClass(task)" @click="toggleCompleted(task)">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="todo-v-container">
|
|
||||||
<input class="todo-title" type="text" v-model="task.title">
|
|
||||||
<input class="todo-description" type="text" v-model="task.description">
|
|
||||||
</div>
|
|
||||||
<div class="todo-meta">
|
|
||||||
<img src="@/assets/img/trash-solid.png" class="btn-icon">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</transition-group>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-container">
|
|
||||||
<button type="button" @click="addItem()" class="btn btn-primary btn-large">Add Item</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import {gqlQuery} from "../util/net";
|
|
||||||
import {store} from "../store";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "TodoRoot",
|
|
||||||
components: [],
|
|
||||||
|
|
||||||
/** Current state of the component */
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
/** whether to show completed items */
|
|
||||||
showCompleted: true,
|
|
||||||
/* array of todo lists currently known */
|
|
||||||
lists: [
|
|
||||||
{ id: 1, title: "Default"},
|
|
||||||
{ id: 2, title: "House" },
|
|
||||||
{ id: 3, title: "Car" },
|
|
||||||
{ id: 4, title: "Work" },
|
|
||||||
],
|
|
||||||
/* the id of the list currently being shown */
|
|
||||||
currentList: 1,
|
|
||||||
/* known tasks */
|
|
||||||
tasks: [
|
|
||||||
{ id: 1, completed: false, title: "Go to dentist", description: "Last time is way too long ago...", list: { id: 1 } },
|
|
||||||
{ id: 2, completed: true, title: "Change engine oil", description: "Use the good one", list: { id: 3 } },
|
|
||||||
{ id: 3, completed: false, title: "Clean windows", description: "Regualar stuff", list: { id: 1 } },
|
|
||||||
{ id: 4, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 5, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 6, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 7, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 8, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 9, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 10, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 11, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 12, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 13, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 14, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 15, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 16, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
{ id: 17, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add an item to the current list.
|
|
||||||
*/
|
|
||||||
addItem() {
|
|
||||||
if (this.currentList === 0) {
|
|
||||||
window.alert("Please select a list at the top right before adding an item.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* fake ID of new item */
|
|
||||||
const newItemId = this.tasks.length + 1;
|
|
||||||
|
|
||||||
this.tasks.push({
|
|
||||||
id: newItemId,
|
|
||||||
completed: false,
|
|
||||||
title: "Enter task here",
|
|
||||||
description: "",
|
|
||||||
list: {
|
|
||||||
id: this.currentList,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/* wait a moment, then scroll list to the bottom */
|
|
||||||
setTimeout(function() {
|
|
||||||
document.getElementById("todo-container").scrollTo({
|
|
||||||
top: 100000,
|
|
||||||
behavior: "smooth"
|
|
||||||
});
|
|
||||||
/* select text in new item's title field */
|
|
||||||
const titleInput = document.querySelector(`#task-${newItemId} .todo-title`);
|
|
||||||
titleInput.setSelectionRange(0, 1000);
|
|
||||||
titleInput.focus();
|
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle completed state of a todo item.
|
|
||||||
* @param task the todo item to toggle.
|
|
||||||
*/
|
|
||||||
toggleCompleted(task) {
|
|
||||||
task.completed = !task.completed;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return class object for a todo checked
|
|
||||||
*
|
|
||||||
* @param task the item
|
|
||||||
*/
|
|
||||||
todoCheckedClass(task) {
|
|
||||||
return {
|
|
||||||
"todo-checked": true,
|
|
||||||
"completed": task.completed,
|
|
||||||
"uncompleted": !task.completed,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return class object for a todo item
|
|
||||||
*
|
|
||||||
* @param task the item
|
|
||||||
*/
|
|
||||||
todoItemClass(task) {
|
|
||||||
return {
|
|
||||||
"todo-item": true,
|
|
||||||
"completed": task.completed,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return array of tasks to show.
|
|
||||||
*/
|
|
||||||
filteredItems() {
|
|
||||||
return this.tasks.filter((item) => {
|
|
||||||
/* hide completed items if the option to show them is not set */
|
|
||||||
if (!this.showCompleted && item.completed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
/* only return items of the current list */
|
|
||||||
if (this.currentList && item.list.id !== this.currentList) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* mounted lifecycle hook.
|
|
||||||
*/
|
|
||||||
mounted() {
|
|
||||||
if (!store.session) {
|
|
||||||
console.error("TodoRoot component must not be loaded if user is not logged in.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Load user info and his/her lists and todos */
|
|
||||||
gqlQuery(`query {
|
|
||||||
getUser(username: "${store.session.userName}") {
|
|
||||||
name
|
|
||||||
lists {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
}
|
|
||||||
tasks {
|
|
||||||
id
|
|
||||||
title
|
|
||||||
description
|
|
||||||
completed
|
|
||||||
list {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}`).then(rspJson => {
|
|
||||||
|
|
||||||
}).catch(err => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
|
|
||||||
#todo-container {
|
|
||||||
height: calc(100vh - 200px);
|
|
||||||
overflow: auto;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-item {
|
|
||||||
display: flex;
|
|
||||||
background-color: var(--color-item-background);
|
|
||||||
box-shadow: var(--item-box-shadow);
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: .3em;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
&.completed {
|
|
||||||
border: 3px solid #41ab57;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
outline: none;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
color: var(--color-item-text);
|
|
||||||
background: transparent;
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.todo-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-v-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
background-color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-meta {
|
|
||||||
padding: 5px 5px 5px 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-checked {
|
|
||||||
width: 80px;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-right: 20px;
|
|
||||||
min-height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 4px solid var(--color-border);
|
|
||||||
background-size: 80%;
|
|
||||||
background-position: center center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
&.uncompleted {
|
|
||||||
&:hover {
|
|
||||||
background-image: url(../assets/img/check-gray.svg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.completed {
|
|
||||||
border-color: #41ab57;
|
|
||||||
background-image: url(../assets/img/check-green.svg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-buttons {
|
|
||||||
float:right;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch {
|
|
||||||
input {
|
|
||||||
transform: scale(150%);
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-enter-active {
|
|
||||||
animation: bounce-in 0.4s;
|
|
||||||
}
|
|
||||||
.list-leave-active {
|
|
||||||
animation: bounce-in 0.4s reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce-in {
|
|
||||||
0% {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter-form {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -7,7 +7,9 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import "bootstrap/dist/css/bootstrap.min.css"
|
||||||
|
import "bootstrap"
|
||||||
|
import 'bootstrap-icons/font/bootstrap-icons.css'
|
||||||
import './assets/main.scss'
|
import './assets/main.scss'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
@ -8,9 +8,9 @@
|
|||||||
import {showError, store} from "../store";
|
import {showError, store} from "../store";
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import OAUthError from "../components/FatalError.vue";
|
|
||||||
import { objectToQueryString } from "../util/net";
|
import { objectToQueryString } from "../util/net";
|
||||||
import {createUser, oauthCallbackHandler} from "../util/oauth";
|
import {oauthCallbackHandler} from "../util/oauth";
|
||||||
|
import {storeInit} from "../store";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@ -28,6 +28,11 @@ const router = createRouter({
|
|||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/AboutView.vue')
|
component: () => import('../views/AboutView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/lists',
|
||||||
|
name: 'lists',
|
||||||
|
component: () => import('../components/Lists.vue')
|
||||||
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -43,7 +48,6 @@ router.beforeEach(async (to, from) => {
|
|||||||
/* Pass the query string to the handler function */
|
/* Pass the query string to the handler function */
|
||||||
try {
|
try {
|
||||||
await oauthCallbackHandler(queryString);
|
await oauthCallbackHandler(queryString);
|
||||||
/* Success; redirect to home */
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = `Failed to get session info from basebox/finish OpenID Connect login: ${err}`;
|
const errorMsg = `Failed to get session info from basebox/finish OpenID Connect login: ${err}`;
|
||||||
console.error(errorMsg);
|
console.error(errorMsg);
|
||||||
@ -51,15 +55,6 @@ router.beforeEach(async (to, from) => {
|
|||||||
showError(errorMsg);
|
showError(errorMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* We have to create the logged-in user explicitly; see comment for `createUser()` */
|
|
||||||
try {
|
|
||||||
await createUser();
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = `Failed to create user: ${err}`;
|
|
||||||
console.error(errorMsg);
|
|
||||||
showError(errorMsg);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {name: 'home'};
|
return {name: 'home'};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
92
src/store.js
92
src/store.js
@ -5,6 +5,7 @@
|
|||||||
* https://basebox.tech
|
* https://basebox.tech
|
||||||
*/
|
*/
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
|
import {gqlQuery} from "./util/net";
|
||||||
|
|
||||||
export const store = reactive({
|
export const store = reactive({
|
||||||
|
|
||||||
@ -20,6 +21,34 @@ export const store = reactive({
|
|||||||
/** Fatal error message, if any */
|
/** Fatal error message, if any */
|
||||||
fatalError: "",
|
fatalError: "",
|
||||||
|
|
||||||
|
/* array of todo lists currently known */
|
||||||
|
lists: [
|
||||||
|
{ id: 1, title: "Default"},
|
||||||
|
{ id: 2, title: "House" },
|
||||||
|
{ id: 3, title: "Car" },
|
||||||
|
{ id: 4, title: "Work" },
|
||||||
|
],
|
||||||
|
/* known tasks */
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, completed: false, title: "Go to dentist", description: "Last time is way too long ago...", list: { id: 1 } },
|
||||||
|
{ id: 2, completed: true, title: "Change engine oil", description: "Use the good one", list: { id: 3 } },
|
||||||
|
{ id: 3, completed: false, title: "Clean windows", description: "Regualar stuff", list: { id: 1 } },
|
||||||
|
{ id: 4, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 5, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 6, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 7, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 8, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 9, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 10, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 11, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 12, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 13, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 14, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 15, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 16, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
{ id: 17, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
|
||||||
|
],
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -49,3 +78,66 @@ export function showError(message) {
|
|||||||
export function clearError() {
|
export function clearError() {
|
||||||
store.fatalError = "";
|
store.fatalError = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize data store and database.
|
||||||
|
*
|
||||||
|
* Must be called by the OAuth login completion handler as soon as session data is available.
|
||||||
|
*
|
||||||
|
* If this is the first run, we need to create the logged on user, since he/she is so far known
|
||||||
|
* only to the OpenID Connect server (Keycloak).
|
||||||
|
*
|
||||||
|
* Additionally, we also create the default list here and save everything in the store.
|
||||||
|
*
|
||||||
|
* @param session - the session info as returned by the OpenID Connect login.
|
||||||
|
*/
|
||||||
|
export async function storeInit(session) {
|
||||||
|
|
||||||
|
/* save user session in the store */
|
||||||
|
store.session = { ...session };
|
||||||
|
store.userName = session.first_name ? session.first_name : session.username;
|
||||||
|
|
||||||
|
/* Create user and default list.
|
||||||
|
* We cannot run these requests in parallel, since the user record must exist in the database
|
||||||
|
* before we can create the default list.
|
||||||
|
*/
|
||||||
|
const tasks = [];
|
||||||
|
tasks.push(
|
||||||
|
gqlQuery(`mutation {
|
||||||
|
createUser(
|
||||||
|
username: "${session.username}",
|
||||||
|
name: "${session.first_name} ${session.last_name}"
|
||||||
|
) {
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}`));
|
||||||
|
tasks.push(gqlQuery(`mutation {
|
||||||
|
createList(
|
||||||
|
title: "Default",
|
||||||
|
user: "${session.username}"
|
||||||
|
) {
|
||||||
|
title
|
||||||
|
}
|
||||||
|
}`));
|
||||||
|
|
||||||
|
const allErrors = [];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
try {
|
||||||
|
await task;
|
||||||
|
} catch (e) {
|
||||||
|
const errStr = e.toString();
|
||||||
|
if (errStr.indexOf("already exists") === -1) {
|
||||||
|
/* this is an error */
|
||||||
|
const e = `Init task failed: ${errStr}`;
|
||||||
|
console.error(e);
|
||||||
|
allErrors.push(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allErrors.length) {
|
||||||
|
showError(allErrors.join("<br><br>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -40,8 +40,26 @@ class GqlError extends Error {
|
|||||||
} else if (error instanceof String) {
|
} else if (error instanceof String) {
|
||||||
errorMessages.push(error);
|
errorMessages.push(error);
|
||||||
|
|
||||||
} else {
|
} else if ('errors' in error) {
|
||||||
/* assume this is a GraphQL server response (JSON) */
|
/** assume this is a GraphQL server response (JSON) of the following form:
|
||||||
|
* {
|
||||||
|
* "errors": [
|
||||||
|
* {
|
||||||
|
* "message": "Cannot query field \"__typenam\" on type \"Query\".",
|
||||||
|
* "locations": [
|
||||||
|
* {
|
||||||
|
* "line": 1,
|
||||||
|
* "column": 2
|
||||||
|
* }
|
||||||
|
* ],
|
||||||
|
* "extensions": {
|
||||||
|
* "code": "GRAPHQL_VALIDATION_FAILED",
|
||||||
|
* "stacktrace": []
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
try {
|
try {
|
||||||
for (const e of error.errors) {
|
for (const e of error.errors) {
|
||||||
let s = e.message;
|
let s = e.message;
|
||||||
@ -57,9 +75,12 @@ class GqlError extends Error {
|
|||||||
errorMessages.push(s);
|
errorMessages.push(s);
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
errorMessages.push("Failed to interpret error: " + e.toString());
|
errorMessages.push("GraphQL error response of unknown format " + e.toString());
|
||||||
errorMessages.push(error.toString());
|
errorMessages.push(error.toString());
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
/* ubnknown error format */
|
||||||
|
errorMessages.push(error.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
/* concat errors into a single message for the parent classes */
|
/* concat errors into a single message for the parent classes */
|
||||||
@ -71,20 +92,6 @@ class GqlError extends Error {
|
|||||||
this.name = "GqlError";
|
this.name = "GqlError";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if this error is an authentication error (401).
|
|
||||||
*/
|
|
||||||
is401() {
|
|
||||||
/* check if one of the server errors has a 401 extension */
|
|
||||||
for (const error of this.errors) {
|
|
||||||
if (error.extensions && error.extensions.code.toString() === "401") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -111,7 +118,7 @@ export function gqlQuery(query)
|
|||||||
fetchOpt.headers["Authorization"] = `Bearer ${store.session.token}`;
|
fetchOpt.headers["Authorization"] = `Bearer ${store.session.token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.info(fetchOpt);
|
console.info(`Sending request:\n${query}`);
|
||||||
|
|
||||||
return fetch(`${store.baseboxHost}/graphql`, fetchOpt).then(
|
return fetch(`${store.baseboxHost}/graphql`, fetchOpt).then(
|
||||||
/* fetch success */
|
/* fetch success */
|
||||||
@ -140,9 +147,19 @@ export function gqlQuery(query)
|
|||||||
return new Promise((resolve, reject) => reject("Unauthorized"));
|
return new Promise((resolve, reject) => reject("Unauthorized"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
/* try to get JSON error object */
|
||||||
reject(new GqlError(response.statusText));
|
try {
|
||||||
});
|
const json = await response.json();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reject(new GqlError(json));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
/* no JSON in response, fall through */
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
reject(new GqlError(response.statusText));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {store} from "../store";
|
import {store} from "../store";
|
||||||
import {gqlQuery} from "./net";
|
import {storeInit} from "../store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle OAuth callback and complete login process.
|
* Handle OAuth callback and complete login process.
|
||||||
@ -52,30 +52,7 @@ export async function oauthCallbackHandler(queryString) {
|
|||||||
throw new Error("Failed to get session data: " + response.statusText);
|
throw new Error("Failed to get session data: " + response.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Store session data */
|
/* Store session data and initialize data store. */
|
||||||
const rspJson = await response.json();
|
const rspJson = await response.json();
|
||||||
store.session = { ...rspJson };
|
await storeInit(rspJson);
|
||||||
store.userName = store.session.first_name ? store.session.first_name : store.session.username;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the user on the broker/todo database.
|
|
||||||
*
|
|
||||||
* Since user management is done by Keycloak, which uses its own user database, we must
|
|
||||||
* make sure that out user is known in the app database (basebox broker). After successful
|
|
||||||
* login, this function must be called to do that.
|
|
||||||
*
|
|
||||||
* @throws Error if the user creation failed.
|
|
||||||
*/
|
|
||||||
export async function createUser() {
|
|
||||||
|
|
||||||
await gqlQuery(`mutation {
|
|
||||||
createUser(
|
|
||||||
username: "${store.session.username}",
|
|
||||||
name: "${store.session.first_name} ${store.session.last_name}"
|
|
||||||
) {
|
|
||||||
username
|
|
||||||
}
|
|
||||||
}`);
|
|
||||||
|
|
||||||
}
|
|
@ -10,11 +10,5 @@ import TheAbout from '../components/TheAbout.vue'
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.about {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
@ -8,8 +8,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { store } from "../store";
|
import { store } from "../store";
|
||||||
import {loggedIn} from "../store";
|
import {loggedIn} from "../store";
|
||||||
import TodoRoot from "../components/TodoRoot.vue";
|
import TodoRoot from "../components/Todo.vue";
|
||||||
import FatalError from "../components/FatalError.vue";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a login.
|
* Perform a login.
|
||||||
@ -25,7 +24,9 @@ function login() {
|
|||||||
|
|
||||||
<!-- Force user to log in before he/she can see tasks. -->
|
<!-- Force user to log in before he/she can see tasks. -->
|
||||||
<div v-if="!loggedIn()" id="login-prompt">
|
<div v-if="!loggedIn()" id="login-prompt">
|
||||||
<p>Your are currently not logged in.</p>
|
<h1>Welcome to basebox' TODO sample app!</h1>
|
||||||
|
|
||||||
|
<p class="mt-5">Your are currently not logged in.</p>
|
||||||
<button class="btn btn-primary" @click="login" type="button">Login</button>
|
<button class="btn btn-primary" @click="login" type="button">Login</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -40,14 +41,8 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#login-prompt {
|
#login-prompt {
|
||||||
margin: 5rem 0;
|
margin: 5rem;
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: .5rem;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
.btn {
|
|
||||||
margin: 3rem 0 0 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
Loading…
Reference in New Issue
Block a user