WIP...
This commit is contained in:
parent
dc3fb83819
commit
3e2ac648c2
@ -1,77 +1,140 @@
|
||||
[resolvers.deleteTask]
|
||||
operation_name = "deleteTask"
|
||||
[resolvers.createTask]
|
||||
operation_name = "createTask"
|
||||
|
||||
[resolvers.deleteTask.resolver]
|
||||
command_type = "SQLDelete"
|
||||
[resolvers.createTask.resolver]
|
||||
command_type = "SQLInsert"
|
||||
|
||||
[resolvers.createTask.resolver.command]
|
||||
table = "Task"
|
||||
columns = []
|
||||
tables = []
|
||||
where_clauses = [["Task", "id", "= '$id'"]]
|
||||
join_clauses = []
|
||||
modify_table = ["Task", ""]
|
||||
modify_values = []
|
||||
aggregate_final_json_result = true
|
||||
where_clauses = []
|
||||
aggregate_result = true
|
||||
|
||||
[[resolvers.createTask.resolver.command.modify_values]]
|
||||
column = "title"
|
||||
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]
|
||||
operation_name = "updateTask"
|
||||
|
||||
[resolvers.updateTask.resolver]
|
||||
command_type = "SQLUpdate"
|
||||
|
||||
[resolvers.updateTask.resolver.command]
|
||||
table = "Task"
|
||||
columns = []
|
||||
tables = []
|
||||
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
|
||||
aggregate_result = true
|
||||
|
||||
[resolvers.createUser]
|
||||
operation_name = "createUser"
|
||||
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||
column = "title"
|
||||
value = "'$title'"
|
||||
|
||||
[resolvers.createUser.resolver]
|
||||
command_type = "SQLInsert"
|
||||
columns = []
|
||||
tables = []
|
||||
where_clauses = []
|
||||
join_clauses = []
|
||||
modify_table = ["User", ""]
|
||||
modify_values = [["username", "'$username'"], ["name", "'$name'"]]
|
||||
aggregate_final_json_result = true
|
||||
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||
column = "description"
|
||||
value = "'$description'"
|
||||
|
||||
[resolvers.createList]
|
||||
operation_name = "createList"
|
||||
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||
column = "completed"
|
||||
value = "$completed"
|
||||
|
||||
[resolvers.createList.resolver]
|
||||
command_type = "SQLInsert"
|
||||
columns = []
|
||||
tables = []
|
||||
where_clauses = []
|
||||
join_clauses = []
|
||||
modify_table = ["List", ""]
|
||||
modify_values = [["title", "'$title'"], ["user_username", "'$user.$username'"]]
|
||||
aggregate_final_json_result = true
|
||||
[[resolvers.updateTask.resolver.command.modify_values]]
|
||||
column = "list_id"
|
||||
value = "'$list.$id'"
|
||||
|
||||
[resolvers.createTask]
|
||||
operation_name = "createTask"
|
||||
|
||||
[resolvers.createTask.resolver]
|
||||
command_type = "SQLInsert"
|
||||
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.updateTask.resolver.command.where_clauses]]
|
||||
table = "Task"
|
||||
column = "id"
|
||||
condition_str = "= '$id'"
|
||||
index = ""
|
||||
|
||||
[resolvers.getUser]
|
||||
operation_name = "getUser"
|
||||
|
||||
[resolvers.getUser.resolver]
|
||||
command_type = "SQLSelect"
|
||||
|
||||
[resolvers.getUser.resolver.command]
|
||||
table = "User"
|
||||
columns = []
|
||||
tables = [["User", ""]]
|
||||
where_clauses = [["User", "username", "= '$username'"]]
|
||||
join_clauses = []
|
||||
modify_table = ["", ""]
|
||||
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>
|
||||
<html lang="en">
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
|
52
package-lock.json
generated
52
package-lock.json
generated
@ -5,8 +5,12 @@
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "todo",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"bootstrap": "^5.3.0-alpha1",
|
||||
"bootstrap-icons": "^1.10.3",
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
@ -379,6 +383,15 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
|
||||
@ -521,6 +534,29 @@
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
@ -1141,6 +1177,11 @@
|
||||
"dev": 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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
|
||||
@ -1268,6 +1309,17 @@
|
||||
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
|
||||
"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": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
|
@ -8,6 +8,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.6",
|
||||
"bootstrap": "^5.3.0-alpha1",
|
||||
"bootstrap-icons": "^1.10.3",
|
||||
"vue": "^3.2.47",
|
||||
"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 { store } from './store.js'
|
||||
import {clearSession} from "./store.js";
|
||||
import FatalError from "./components/FatalError.vue";
|
||||
|
||||
/**
|
||||
* Logout the user.
|
||||
@ -13,98 +12,64 @@ function logOut() {
|
||||
location.href = "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove error alert from the screen.
|
||||
*/
|
||||
function dismissError() {
|
||||
store.fatalError = "";
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="basebox logo" class="logo" src="@/assets/img/basebox_logo.svg" />
|
||||
<header class="sticky-top">
|
||||
<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">
|
||||
<HelloWorld />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
<a v-if="store.session" href="" @click="logOut()">Log Out</a>
|
||||
</nav>
|
||||
|
||||
<FatalError v-if="store.fatalError" :error-msg="store.fatalError"/>
|
||||
|
||||
|
||||
</div>
|
||||
<!-- navigation items are shown only if logged in -->
|
||||
<div v-if="store.session.token" class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<RouterLink to="/about" class="nav-link" active-class="active">About</RouterLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<RouterLink to="/lists" class="nav-link" active-class="active">Lists</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="navbar-nav mb-2 mb-lg-0">
|
||||
<li class="navbar-text">Hello, {{ store.session.username }}!</li>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
max-width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
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>
|
||||
|
@ -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';
|
||||
|
||||
#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;
|
||||
main {
|
||||
padding-top: 20px;
|
||||
label {
|
||||
text-transform: uppercase;
|
||||
font-size: .8rem;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 6px 8px;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
background-color: var(--color-input-background);
|
||||
color: var(--color-input);
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
outline: none;
|
||||
margin-bottom: 15px;
|
||||
color: var(--color-item-text);
|
||||
background: transparent;
|
||||
&:focus {
|
||||
border-color: var(--color-input-border-focus);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
.item-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
margin-top: 25px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
.item-v-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
&.btn-primary {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border-color: #1890ff;
|
||||
.item-meta {
|
||||
padding: 5px 5px 5px 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
&.btn-large {
|
||||
font-size: 1.25rem;
|
||||
padding: 6px 25px;
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-container {
|
||||
margin: 30px 0;
|
||||
.list-enter-active {
|
||||
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 App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import "bootstrap/dist/css/bootstrap.min.css"
|
||||
import "bootstrap"
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css'
|
||||
import './assets/main.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
@ -8,9 +8,9 @@
|
||||
import {showError, store} from "../store";
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import OAUthError from "../components/FatalError.vue";
|
||||
import { objectToQueryString } from "../util/net";
|
||||
import {createUser, oauthCallbackHandler} from "../util/oauth";
|
||||
import {oauthCallbackHandler} from "../util/oauth";
|
||||
import {storeInit} from "../store";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@ -28,6 +28,11 @@ const router = createRouter({
|
||||
// which is lazy-loaded when the route is visited.
|
||||
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 */
|
||||
try {
|
||||
await oauthCallbackHandler(queryString);
|
||||
/* Success; redirect to home */
|
||||
} catch (err) {
|
||||
const errorMsg = `Failed to get session info from basebox/finish OpenID Connect login: ${err}`;
|
||||
console.error(errorMsg);
|
||||
@ -51,15 +55,6 @@ router.beforeEach(async (to, from) => {
|
||||
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'};
|
||||
}
|
||||
|
||||
|
92
src/store.js
92
src/store.js
@ -5,6 +5,7 @@
|
||||
* https://basebox.tech
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import {gqlQuery} from "./util/net";
|
||||
|
||||
export const store = reactive({
|
||||
|
||||
@ -20,6 +21,34 @@ export const store = reactive({
|
||||
/** Fatal error message, if any */
|
||||
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() {
|
||||
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) {
|
||||
errorMessages.push(error);
|
||||
|
||||
} else {
|
||||
/* assume this is a GraphQL server response (JSON) */
|
||||
} else if ('errors' in error) {
|
||||
/** 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 {
|
||||
for (const e of error.errors) {
|
||||
let s = e.message;
|
||||
@ -57,9 +75,12 @@ class GqlError extends Error {
|
||||
errorMessages.push(s);
|
||||
}
|
||||
} catch(e) {
|
||||
errorMessages.push("Failed to interpret error: " + e.toString());
|
||||
errorMessages.push("GraphQL error response of unknown format " + e.toString());
|
||||
errorMessages.push(error.toString());
|
||||
}
|
||||
} else {
|
||||
/* ubnknown error format */
|
||||
errorMessages.push(error.toString());
|
||||
}
|
||||
|
||||
/* concat errors into a single message for the parent classes */
|
||||
@ -71,20 +92,6 @@ class GqlError extends Error {
|
||||
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}`;
|
||||
}
|
||||
|
||||
console.info(fetchOpt);
|
||||
console.info(`Sending request:\n${query}`);
|
||||
|
||||
return fetch(`${store.baseboxHost}/graphql`, fetchOpt).then(
|
||||
/* fetch success */
|
||||
@ -140,9 +147,19 @@ export function gqlQuery(query)
|
||||
return new Promise((resolve, reject) => reject("Unauthorized"));
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
reject(new GqlError(response.statusText));
|
||||
});
|
||||
/* try to get JSON error object */
|
||||
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 {gqlQuery} from "./net";
|
||||
import {storeInit} from "../store";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/* Store session data */
|
||||
/* Store session data and initialize data store. */
|
||||
const rspJson = await response.json();
|
||||
store.session = { ...rspJson };
|
||||
store.userName = store.session.first_name ? store.session.first_name : store.session.username;
|
||||
await storeInit(rspJson);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -8,8 +8,7 @@
|
||||
<script setup>
|
||||
import { store } from "../store";
|
||||
import {loggedIn} from "../store";
|
||||
import TodoRoot from "../components/TodoRoot.vue";
|
||||
import FatalError from "../components/FatalError.vue";
|
||||
import TodoRoot from "../components/Todo.vue";
|
||||
|
||||
/**
|
||||
* Perform a login.
|
||||
@ -25,7 +24,9 @@ function login() {
|
||||
|
||||
<!-- Force user to log in before he/she can see tasks. -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -40,14 +41,8 @@ main {
|
||||
}
|
||||
|
||||
#login-prompt {
|
||||
margin: 5rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: .5rem;
|
||||
padding: 2rem;
|
||||
margin: 5rem;
|
||||
text-align: center;
|
||||
.btn {
|
||||
margin: 3rem 0 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user