Groundwork

This commit is contained in:
2022-05-08 15:35:43 +03:00
parent 44f6c82a4e
commit 17aa8c75b1
52 changed files with 13631 additions and 29700 deletions

3
frontend/.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

17
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,17 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: [
'plugin:vue/vue3-essential',
'@vue/airbnb',
],
parserOptions: {
parser: 'babel-eslint',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
};

2
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
/dist

5
frontend/babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
};

13391
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "worktime",
"author": "Anatoly Kopyl",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"dev": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"predeploy": "npm run build",
"deploy": "gh-pages -d dist"
},
"dependencies": {
"core-js": "^3.6.5",
"register-service-worker": "^1.7.1",
"reset-css": "^5.0.1",
"vue": "^3.0.0",
"vue-router": "^4.0.0-0",
"vuex": "^4.0.0-0",
"vuex-persist": "^3.1.3"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-pwa": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-airbnb": "^5.0.2",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-vue": "^7.0.0",
"gh-pages": "^3.2.3",
"sass": "^1.26.5",
"sass-loader": "^8.0.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>img/icons/favicon.svg">
<link rel="apple-touch-icon" href="<%= BASE_URL %>img/icons/apple-touch-icon.png"/>
<title>Worktime</title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

19
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<div class="app-backdrop"></div>
<router-view/>
<div id="modalSpot" />
</template>
<style lang="scss">
@import "@/scss/style.scss";
.app-backdrop {
z-index: -10000;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: $darker;
}
</style>

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x" viewBox="0 0 16 16">
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#f7f7f7" class="bi bi-gear" viewBox="0 0 16 16">
<path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
<path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pause-fill" viewBox="0 0 16 16">
<path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16">
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#f7f7f7" class="bi bi-clock-history" viewBox="0 0 16 16">
<path d="M8.515 1.019A7 7 0 0 0 8 1V0a8 8 0 0 1 .589.022l-.074.997zm2.004.45a7.003 7.003 0 0 0-.985-.299l.219-.976c.383.086.76.2 1.126.342l-.36.933zm1.37.71a7.01 7.01 0 0 0-.439-.27l.493-.87a8.025 8.025 0 0 1 .979.654l-.615.789a6.996 6.996 0 0 0-.418-.302zm1.834 1.79a6.99 6.99 0 0 0-.653-.796l.724-.69c.27.285.52.59.747.91l-.818.576zm.744 1.352a7.08 7.08 0 0 0-.214-.468l.893-.45a7.976 7.976 0 0 1 .45 1.088l-.95.313a7.023 7.023 0 0 0-.179-.483zm.53 2.507a6.991 6.991 0 0 0-.1-1.025l.985-.17c.067.386.106.778.116 1.17l-1 .025zm-.131 1.538c.033-.17.06-.339.081-.51l.993.123a7.957 7.957 0 0 1-.23 1.155l-.964-.267c.046-.165.086-.332.12-.501zm-.952 2.379c.184-.29.346-.594.486-.908l.914.405c-.16.36-.345.706-.555 1.038l-.845-.535zm-.964 1.205c.122-.122.239-.248.35-.378l.758.653a8.073 8.073 0 0 1-.401.432l-.707-.707z"/>
<path d="M8 1a7 7 0 1 0 4.95 11.95l.707.707A8.001 8.001 0 1 1 8 0v1z"/>
<path d="M7.5 3a.5.5 0 0 1 .5.5v5.21l3.248 1.856a.5.5 0 0 1-.496.868l-3.5-2A.5.5 0 0 1 7 9V3.5a.5.5 0 0 1 .5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stop-fill" viewBox="0 0 16 16">
<path d="M5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5A1.5 1.5 0 0 1 5 3.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#f7f7f7" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>

After

Width:  |  Height:  |  Size: 568 B

View File

@@ -0,0 +1,19 @@
<svg width="260" height="56" viewBox="0 0 260 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="260" height="56" fill="black"/>
<rect x="44" y="16" width="24" height="24" rx="12" fill="#FC3F1D"/>
<path d="M57.6912 35.212H60.1982V20.812H56.5516C52.8843 20.812 50.9574 22.6975 50.9574 25.4739C50.9574 27.6909 52.0141 28.9962 53.8995 30.3429L50.6259 35.212H53.3401L56.9867 29.7628L55.7228 28.9133C54.1896 27.8773 53.4437 27.0693 53.4437 25.3288C53.4437 23.7956 54.5211 22.7596 56.5723 22.7596H57.6912V35.212Z" fill="white"/>
<path d="M81.3961 21.528H85.3001C86.6655 21.528 87.7055 21.7413 88.4201 22.168C89.1348 22.5947 89.4921 23.3147 89.4921 24.328C89.4921 24.744 89.4335 25.1067 89.3161 25.416C89.1988 25.7147 89.0281 25.976 88.8041 26.2C88.5908 26.4133 88.3295 26.5893 88.0201 26.728C87.7108 26.8667 87.3695 26.9733 86.9961 27.048C87.9561 27.1653 88.6815 27.4267 89.1721 27.832C89.6628 28.2373 89.9081 28.856 89.9081 29.688C89.9081 30.2853 89.7908 30.7973 89.5561 31.224C89.3215 31.64 88.9961 31.9813 88.5801 32.248C88.1641 32.504 87.6735 32.696 87.1081 32.824C86.5428 32.9413 85.9295 33 85.2681 33H81.3961V21.528ZM83.3321 23.208V26.264H85.3961C86.0361 26.264 86.5535 26.1307 86.9481 25.864C87.3428 25.5867 87.5401 25.1547 87.5401 24.568C87.5401 24.0347 87.3588 23.6773 86.9961 23.496C86.6441 23.304 86.1428 23.208 85.4921 23.208H83.3321ZM83.3321 27.928V31.336H85.4601C85.8228 31.336 86.1535 31.3093 86.4521 31.256C86.7508 31.192 87.0068 31.096 87.2201 30.968C87.4335 30.8293 87.5988 30.6533 87.7161 30.44C87.8335 30.216 87.8921 29.944 87.8921 29.624C87.8921 28.9947 87.6841 28.5573 87.2681 28.312C86.8628 28.056 86.2068 27.928 85.3001 27.928H83.3321Z" fill="white"/>
<path d="M95.2729 33.16C94.6862 33.16 94.1422 33.064 93.6409 32.872C93.1395 32.68 92.7022 32.4027 92.3289 32.04C91.9662 31.6667 91.6782 31.2133 91.4649 30.68C91.2622 30.1467 91.1609 29.5333 91.1609 28.84C91.1609 28.1467 91.2622 27.5333 91.4649 27C91.6782 26.4667 91.9662 26.0187 92.3289 25.656C92.7022 25.2827 93.1395 25.0053 93.6409 24.824C94.1422 24.632 94.6862 24.536 95.2729 24.536C95.8595 24.536 96.4035 24.632 96.9049 24.824C97.4062 25.0053 97.8435 25.2827 98.2169 25.656C98.5902 26.0187 98.8835 26.4667 99.0969 27C99.3102 27.5333 99.4169 28.1467 99.4169 28.84C99.4169 29.5333 99.3102 30.1467 99.0969 30.68C98.8835 31.2133 98.5902 31.6667 98.2169 32.04C97.8435 32.4027 97.4062 32.68 96.9049 32.872C96.4035 33.064 95.8595 33.16 95.2729 33.16ZM95.2729 31.608C95.9129 31.608 96.4409 31.384 96.8569 30.936C97.2835 30.488 97.4969 29.7893 97.4969 28.84C97.4969 27.9013 97.2835 27.208 96.8569 26.76C96.4409 26.3013 95.9129 26.072 95.2729 26.072C94.6435 26.072 94.1155 26.3013 93.6889 26.76C93.2729 27.208 93.0649 27.9013 93.0649 28.84C93.0649 29.7893 93.2729 30.488 93.6889 30.936C94.1155 31.384 94.6435 31.608 95.2729 31.608Z" fill="white"/>
<path d="M104.714 23.704C104.266 23.704 103.876 23.6453 103.546 23.528C103.226 23.4107 102.954 23.2507 102.73 23.048C102.516 22.8347 102.356 22.5893 102.25 22.312C102.143 22.024 102.09 21.72 102.09 21.4H103.722C103.722 21.7733 103.807 22.0453 103.978 22.216C104.159 22.376 104.404 22.456 104.714 22.456C105.023 22.456 105.263 22.376 105.434 22.216C105.604 22.0453 105.69 21.7733 105.69 21.4H107.338C107.338 21.72 107.284 22.024 107.178 22.312C107.071 22.5893 106.906 22.8347 106.682 23.048C106.468 23.2507 106.196 23.4107 105.866 23.528C105.535 23.6453 105.151 23.704 104.714 23.704ZM102.81 30.488L106.65 24.696H108.474V33H106.634V27.24L102.826 33H100.97V24.696H102.81V30.488Z" fill="white"/>
<path d="M114.275 26.2V33H112.419V26.2H109.907V24.696H116.835V26.2H114.275Z" fill="white"/>
<path d="M120.091 30.488L123.931 24.696H125.755V33H123.915V27.24L120.107 33H118.251V24.696H120.091V30.488Z" fill="white"/>
<path d="M135.184 33.16C134.512 33.16 133.91 33.064 133.376 32.872C132.843 32.6693 132.39 32.3867 132.016 32.024C131.643 31.6507 131.355 31.1973 131.152 30.664C130.95 30.1307 130.848 29.5227 130.848 28.84C130.848 28.168 130.95 27.5653 131.152 27.032C131.355 26.4987 131.643 26.0507 132.016 25.688C132.39 25.3147 132.848 25.032 133.392 24.84C133.936 24.6373 134.544 24.536 135.216 24.536C135.792 24.536 136.294 24.6 136.72 24.728C137.158 24.856 137.504 25.016 137.76 25.208V26.744C137.43 26.5307 137.067 26.3653 136.672 26.248C136.288 26.1307 135.84 26.072 135.328 26.072C133.611 26.072 132.752 26.9947 132.752 28.84C132.752 30.6853 133.595 31.608 135.28 31.608C135.824 31.608 136.288 31.5493 136.672 31.432C137.067 31.304 137.43 31.144 137.76 30.952V32.488C137.483 32.6693 137.136 32.8293 136.72 32.968C136.304 33.096 135.792 33.16 135.184 33.16Z" fill="white"/>
<path d="M143.097 25.128C143.097 24.52 143.204 23.992 143.417 23.544C143.631 23.0853 143.929 22.712 144.313 22.424C144.697 22.1253 145.156 21.9013 145.689 21.752C146.233 21.6027 146.836 21.528 147.497 21.528H151.225V33H149.273V28.584H147.497L144.569 33H142.313L145.545 28.296C144.713 28.0933 144.095 27.7253 143.689 27.192C143.295 26.648 143.097 25.96 143.097 25.128ZM149.273 26.968V23.208H147.481C146.756 23.208 146.175 23.352 145.737 23.64C145.311 23.9173 145.097 24.3973 145.097 25.08C145.097 25.752 145.289 26.2373 145.673 26.536C146.057 26.824 146.601 26.968 147.305 26.968H149.273Z" fill="white"/>
<path d="M158.845 29.496H155.341V33H153.485V24.696H155.341V27.992H158.845V24.696H160.701V33H158.845V29.496Z" fill="white"/>
<path d="M162.558 31.496C162.804 31.2827 162.996 30.9893 163.134 30.616C163.284 30.2427 163.401 29.7787 163.486 29.224C163.572 28.6587 163.636 28.0027 163.678 27.256C163.721 26.5093 163.758 25.656 163.79 24.696H169.87V31.496H171.118V35.368H169.502L169.358 33H163.63L163.486 35.368H161.854V31.496H162.558ZM168.014 31.496V26.2H165.39C165.337 27.512 165.246 28.6 165.118 29.464C165.001 30.3173 164.804 30.9947 164.526 31.496H168.014Z" fill="white"/>
<path d="M179.273 32.392C179.156 32.4667 179.012 32.552 178.841 32.648C178.67 32.7333 178.468 32.8133 178.233 32.888C177.998 32.9627 177.726 33.0267 177.417 33.08C177.108 33.1333 176.756 33.16 176.361 33.16C174.836 33.16 173.694 32.7813 172.937 32.024C172.19 31.2667 171.817 30.2053 171.817 28.84C171.817 28.168 171.918 27.5653 172.121 27.032C172.324 26.4987 172.606 26.0507 172.969 25.688C173.332 25.3147 173.764 25.032 174.265 24.84C174.766 24.6373 175.316 24.536 175.913 24.536C176.532 24.536 177.086 24.6373 177.577 24.84C178.078 25.0427 178.489 25.3467 178.809 25.752C179.129 26.1573 179.348 26.6587 179.465 27.256C179.593 27.8533 179.598 28.552 179.481 29.352H173.737C173.812 30.1093 174.062 30.68 174.489 31.064C174.916 31.4373 175.582 31.624 176.489 31.624C177.15 31.624 177.7 31.544 178.137 31.384C178.585 31.2133 178.964 31.0373 179.273 30.856V32.392ZM175.913 26.008C175.369 26.008 174.91 26.1733 174.537 26.504C174.164 26.8347 173.918 27.3253 173.801 27.976H177.705C177.726 27.304 177.577 26.808 177.257 26.488C176.937 26.168 176.489 26.008 175.913 26.008Z" fill="white"/>
<path d="M183.78 29.624H183.06V33H181.204V24.696H183.06V28.12H183.844L186.484 24.696H188.436L185.3 28.696L188.564 33H186.372L183.78 29.624Z" fill="white"/>
<path d="M193.231 33.16C192.559 33.16 191.957 33.064 191.423 32.872C190.89 32.6693 190.437 32.3867 190.063 32.024C189.69 31.6507 189.402 31.1973 189.199 30.664C188.997 30.1307 188.895 29.5227 188.895 28.84C188.895 28.168 188.997 27.5653 189.199 27.032C189.402 26.4987 189.69 26.0507 190.063 25.688C190.437 25.3147 190.895 25.032 191.439 24.84C191.983 24.6373 192.591 24.536 193.263 24.536C193.839 24.536 194.341 24.6 194.767 24.728C195.205 24.856 195.551 25.016 195.807 25.208V26.744C195.477 26.5307 195.114 26.3653 194.719 26.248C194.335 26.1307 193.887 26.072 193.375 26.072C191.658 26.072 190.799 26.9947 190.799 28.84C190.799 30.6853 191.642 31.608 193.327 31.608C193.871 31.608 194.335 31.5493 194.719 31.432C195.114 31.304 195.477 31.144 195.807 30.952V32.488C195.53 32.6693 195.183 32.8293 194.767 32.968C194.351 33.096 193.839 33.16 193.231 33.16Z" fill="white"/>
<path d="M203.16 21.528V33H201.224V21.528H203.16Z" fill="white"/>
<path d="M205.615 21.528H209.487C210.298 21.528 211.05 21.6187 211.743 21.8C212.447 21.9813 213.055 22.28 213.567 22.696C214.09 23.112 214.495 23.6613 214.783 24.344C215.082 25.0267 215.231 25.8693 215.231 26.872C215.231 27.8853 215.082 28.776 214.783 29.544C214.495 30.3013 214.084 30.936 213.551 31.448C213.028 31.96 212.404 32.3493 211.679 32.616C210.964 32.872 210.18 33 209.327 33H205.615V21.528ZM207.551 23.208V31.336H209.391C209.956 31.336 210.468 31.2507 210.927 31.08C211.396 30.9093 211.796 30.648 212.127 30.296C212.468 29.944 212.73 29.496 212.911 28.952C213.103 28.3973 213.199 27.7413 213.199 26.984C213.199 26.2373 213.108 25.624 212.927 25.144C212.746 24.6533 212.49 24.264 212.159 23.976C211.839 23.688 211.455 23.4907 211.007 23.384C210.559 23.2667 210.074 23.208 209.551 23.208H207.551Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

@@ -0,0 +1,38 @@
<template>
<div class="auth">
<img
class="button"
src="@/assets/yandex_login.svg"
@click="yandexLogin"
>
</div>
</template>
<script>
export default {
methods: {
yandexLogin() {
const baseUrl = 'https://oauth.yandex.ru/authorize';
const params = new URLSearchParams({
response_type: 'token',
client_id: '081b0b290f0847e18b19771c840d1075',
redirect_uri: 'http://localhost:8080/auth-redirect',
state: 'yandex',
});
window.location = `${baseUrl}?${params.toString()}`;
},
},
};
</script>
<style lang="scss" scoped>
.auth {
text-align: center;
margin-top: 32px;
.button {
cursor: pointer;
border-radius: 10px;
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<teleport to="#modalSpot">
<div
class="modal"
:class="{hidden: !active}"
>
<div class="bg" @click="close" />
<div class="window">
<div class="close" @click="close"><img src="@/assets/cross.svg" /></div>
<slot />
</div>
</div>
</teleport>
</template>
<script>
export default {
data() {
return {
active: false,
};
},
methods: {
open() {
this.active = true;
},
close() {
this.active = false;
},
},
};
</script>
<style lang="scss" scoped>
.modal {
position: fixed;
transition: all .4s;
.bg {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(90, 90, 90, 0.28);
backdrop-filter: blur(12px);
}
.window {
z-index: 1000;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 16px;
border-radius: 16px;
background: $lighter;
color: $dark;
width: 340px;
.close {
position: absolute;
top: 16px;
right: 16px;
cursor: pointer;
}
}
&.hidden {
opacity: 0;
pointer-events: none;
}
}
@media screen and (max-width: $max-width) {
.modal {
.window {
left: 0;
width: 100%;
bottom: 0;
top: unset;
padding-bottom: 32px;
box-sizing: border-box;
border-radius: 16px 16px 0 0;
transform: translate(0, 0);
transition: transform .4s;
}
&.hidden {
.window {
opacity: 1;
transform: translate(0, 100%);
}
}
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<Modal ref="modal">
<div class="title">Settings</div>
<div class="setting">
<input type="checkbox" id="reset" v-model="resetAtMidnight">
<label for="reset">Reset all tasks to 0 at midnight</label>
</div>
<Auth />
</Modal>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
import Modal from '@/components/Modal.vue';
import Auth from './Auth.vue';
export default {
components: {
Modal,
Auth,
},
data() {
return {
resetAtMidnight: undefined,
};
},
computed: {
...mapState(['midnightReset']),
},
watch: {
resetAtMidnight(newVal) {
this.setMidnightReset(newVal);
},
},
beforeMount() {
this.resetAtMidnight = this.midnightReset;
},
methods: {
...mapMutations(['setMidnightReset']),
open() {
this.$refs.modal.open();
},
},
};
</script>
<style lang="scss" scoped>
.setting {
margin-top: 16px;
label {
margin-left: 16px;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="category-bar">
<input
class="add-category"
v-model="newCategory"
placeholder="New category"
@keypress.enter="addCategory"
@click="hoverInput"
@mouseenter="hoverInput"
@mouseleave="blurInput"
/>
<div class="categories">
<div
class="category"
v-for="category in categories"
:key="category"
:style="{ background: stringToColor(category) }"
:class="{ selected: selectedCategory === category }"
@click="selectCategory(category)"
>
{{ category }}
<span @click.stop="removeCategory(category)"><img src="@/assets/cross.svg" /></span>
</div>
</div>
<div class="settings" @click="openSettings">
<img src="@/assets/gear.svg" />
</div>
<SettingsModal ref="settingsModal" />
</div>
</template>
<script>
import { mapState } from 'vuex';
import toColor from '@/stringToColor';
import SettingsModal from './SettingsModal.vue';
export default {
components: {
SettingsModal,
},
data() {
return {
newCategory: '+',
selectedCategory: undefined,
};
},
methods: {
addCategory(event) {
this.$store.commit('addCategory', this.newCategory);
this.newCategory = '';
this.blurInput(event);
},
removeCategory(category) {
this.$store.commit('removeCategory', category);
if (category === this.selectedCategory) {
this.selectCategory();
}
},
hoverInput(event) {
this.newCategory = '';
event.target.classList.add('hover');
},
blurInput(event) {
this.newCategory = '+';
event.target.classList.remove('hover');
event.target.blur();
},
stringToColor(str) {
return toColor(str);
},
selectCategory(category) {
if (this.selectedCategory === category) {
this.selectedCategory = undefined;
} else {
this.selectedCategory = category;
}
this.$emit('select', this.selectedCategory);
},
openSettings() {
this.$refs.settingsModal.open();
},
},
computed: {
...mapState(['categories']),
},
};
</script>
<style lang="scss" scoped>
.category-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
//border-bottom: 1px solid $light;
height: 48px;
background: $dark;
display: flex;
align-items: center;
padding: 0 64px;
box-sizing: border-box;
z-index: 900;
.add-category {
color: $darker;
background: $light;
height: 32px;
padding: 4px 12px;
width: 32px;
box-sizing: border-box;
transition: width 0.6s;
&.hover {
width: 220px;
}
}
.categories {
display: flex;
margin: 0 64px 0 32px;
overflow-x: scroll;
.category {
margin-right: 16px;
border: 3px solid $dark;
&.selected {
border: 3px double $dark;
}
}
}
.settings {
position: absolute;
right: 64px;
height: 32px;
width: 32px;
cursor: pointer;
> * {
height: inherit;
}
}
}
@media screen and (max-width: $max-width) {
.category-bar {
padding: 0 0 0 8px;
.categories {
margin-right: 0;
}
.settings {
position: fixed;
bottom: 16px;
right: 16px;
}
}
}
</style>

11
frontend/src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue';
import App from './App.vue';
import './registerServiceWorker';
import router from './router';
import store from './store';
const app = createApp(App);
app.use(store);
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker';
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log(
'App is being served from cache by a service worker.\n'
+ 'For more details, visit https://goo.gl/AFskqB',
);
},
registered() {
console.log('Service worker has been registered.');
},
cached() {
console.log('Content has been cached for offline use.');
},
updatefound() {
console.log('New content is downloading.');
},
updated() {
console.log('New content is available; please refresh.');
},
offline() {
console.log('No internet connection found. App is running in offline mode.');
},
error(error) {
console.error('Error during service worker registration:', error);
},
});
}

View File

@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home/Index.vue';
import AuthRedirect from '../views/AuthRedirect.vue';
const routes = [
{
path: '/',
name: 'Home',
component: Home,
},
{
path: '/auth-redirect',
name: 'AuthRedirect',
component: AuthRedirect,
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;

View File

@@ -0,0 +1,7 @@
$dark: #252525;
$darker: #121212;
$light: #f7f7f7;
$lighter: white;
$max-width: 1024px;

View File

@@ -0,0 +1,49 @@
@import 'reset-css';
@import url('https://fonts.googleapis.com/css2?family=Scada:wght@400;700&display=swap');
#app {
font-family: 'Scada', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 16px;
color: $light;
background: $darker;
padding-top: 48px;
input, button {
border: none;
border-radius: 16px;
font-size: 16px;
cursor: pointer;
&::placeholder {
color: $dark;
opacity: 1;
}
&:focus-visible {
outline: none;
}
}
.category {
border-radius: 16px;
height: 1rem;
padding: 6px 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
color: $darker;
span {
width: 16px;
height: 16px;
margin-left: 4px;
vertical-align: middle;
}
}
}

104
frontend/src/store/index.js Normal file
View File

@@ -0,0 +1,104 @@
import { createStore } from 'vuex';
import VuexPersistence from 'vuex-persist';
const vuexLocal = new VuexPersistence({
storage: window.localStorage,
});
export default createStore({
state: {
categories: [],
tasks: [],
midnightReset: false,
lastReset: new Date(),
token: null,
darkTheme: true,
},
mutations: {
addCategory(state, category) {
if (category && !state.categories.includes(category)) {
state.categories = [...state.categories, category];
}
},
removeCategory(state, category) {
state.categories = state.categories.filter((element) => element !== category);
state.tasks = state.tasks.map((task) => {
const newTask = task;
if (newTask.category === category) newTask.category = undefined;
return newTask;
});
},
assignCategory(state, { name, category }) {
state.tasks = state.tasks.map((task) => {
const newTask = task;
if (newTask.name === name) {
newTask.category = category;
}
return newTask;
});
},
addTask(state, name) {
if (name) {
const task = {
name,
startedAt: undefined,
running: false,
totalTime: 0,
category: undefined,
};
state.tasks = [...state.tasks, task];
}
},
removeTask(state, name) {
state.tasks = state.tasks.filter((task) => task.name !== name);
},
removeAllTasks(state) {
state.tasks = [];
},
startTask(state, name) {
state.tasks = state.tasks.map((task) => {
const newTask = task;
if (newTask.name === name) {
newTask.running = true;
newTask.startedAt = Date.now();
} else if (newTask.running) {
newTask.running = false;
newTask.totalTime += Date.now() - newTask.startedAt;
newTask.startedAt = undefined;
}
return newTask;
});
},
stopTask(state, name) {
state.tasks = state.tasks.map((task) => {
const newTask = task;
if (newTask.name === name) {
newTask.running = false;
newTask.totalTime += Date.now() - newTask.startedAt;
newTask.startedAt = undefined;
}
return newTask;
});
},
resetTasks(state) {
state.tasks = state.tasks.map((task) => {
const newTask = task;
newTask.running = false;
newTask.totalTime = 0;
newTask.startedAt = undefined;
return newTask;
});
state.lastReset = new Date();
},
setMidnightReset(state, value) {
state.midnightReset = !!value;
},
setToken(state, token) {
state.token = token;
},
},
plugins: [vuexLocal.plugin],
});

View File

@@ -0,0 +1,20 @@
/* eslint-disable */
export default function stringToColor(str) {
var colors = ['#FF6633', '#FFB399', '#FF33FF', '#FFFF99', '#00B3E6',
'#3366E6', '#99FF99', '#B34D4D', '#80B300', '#E6B3B3',
'#6680B3', '#66991A', '#FF99E6', '#CCFF1A', '#FF1A66',
'#E6331A', '#33FFCC', '#B366CC', '#CC80CC', '#991AFF',
'#E666FF', '#4DB3FF', '#1AB399', '#E666B3', '#CC9999',
'#B3B31A', '#00E680', '#E6FF80', '#1AFF33', '#FF3380',
'#CCCC00', '#66E64D', '#4D80CC', '#E64D66', '#4DB380',
'#FF4D4D', '#99E6E6', '#6666FF'];
var hash = 0;
if (str.length === 0) return hash;
for (var i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
hash = ((hash % colors.length) + colors.length) % colors.length;
return colors[hash];
}

View File

@@ -0,0 +1,25 @@
<template>
<div>
You should be redirected in a moment.
</div>
</template>
<script>
export default {
async mounted() {
const baseUrl = 'http://localhost:3000/api/login';
const token = /access_token=([^&]+)/.exec(this.$route.hash)[1];
const authProvider = /state=([^&]+)/.exec(this.$route.hash)[1];
const response = await fetch(baseUrl, {
method: 'POST',
credentials: 'include',
body: JSON.stringify({
token,
authProvider,
}),
});
console.log(await response.json());
},
};
</script>

View File

@@ -0,0 +1,66 @@
<template>
<Modal ref="modal">
<div class="title">Assign Category</div>
<div
v-for="category in categories"
:key="category"
class="category"
:style="{ background: stringToColor(category) }"
@click="assignCategory(category)"
>
{{ category }}
</div>
</Modal>
</template>
<script>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import Modal from '@/components/Modal.vue';
import toColor from '@/stringToColor';
export default {
components: { Modal },
props: {
name: {
type: String,
required: true,
},
},
setup(props) {
const store = useStore();
const modal = ref(null);
const open = () => {
modal.value.open();
};
const close = () => {
modal.value.close();
};
const stringToColor = (str) => toColor(str);
const assignCategory = (category) => {
store.commit('assignCategory', { name: props.name, category });
modal.value.close();
};
return {
modal,
open,
close,
assignCategory,
stringToColor,
categories: computed(() => store.state.categories),
};
},
};
</script>
<style lang="scss" scoped>
.category {
margin-top: 12px;
border-radius: 24px !important;
padding: 12px 24px !important;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<div class="global-controls">
<Time
:time="totalTime"
:pulsing="atLeastOneRunning"
/>
<div>
Total
</div>
<div class="buttons">
<transition name="slide-up">
<div
v-if="atLeastOneRunning"
class="stop"
@click="stop"
>
<img src="@/assets/stop.svg" />
</div>
</transition>
<div @click="$refs.resetAllModal.open()">
<img src="@/assets/reset.svg" />
</div>
<div @click="$refs.removeAllModal.open()">
<img src="@/assets/trash.svg" />
</div>
</div>
<ResetAllModal ref="resetAllModal"/>
<RemoveAllModal ref="removeAllModal"/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import Time from './Time.vue';
import RemoveAllModal from './RemoveAllModal.vue';
import ResetAllModal from './ResetAllModal.vue';
export default {
components: {
Time,
RemoveAllModal,
ResetAllModal,
},
data() {
return {
totalTime: 0,
};
},
computed: {
...mapState(['tasks']),
atLeastOneRunning() {
return this.tasks.reduce(
(atLeastOneRunning, task) => task.running || atLeastOneRunning,
false,
);
},
},
beforeMount() {
setInterval(() => {
this.totalTime = this.getTotalTime();
}, 1000);
},
methods: {
getTotalTime() {
return this.tasks.reduce((acc, task) => {
if (task.startedAt) {
const timePassed = Date.now() - task.startedAt;
return acc + task.totalTime + timePassed;
}
return acc + task.totalTime;
}, 0);
},
stop() {
this.tasks.forEach((task) => {
if (task.running) {
this.$store.commit('stopTask', task.name);
}
});
},
},
};
</script>
<style lang="scss" scoped>
.global-controls {
max-width: $max-width;
height: 32px;
margin: 32px auto;
padding: 0 8px;
box-sizing: border-box;
display: flex;
align-items: center;
.time {
min-width: 104px;
}
.buttons {
display: flex;
flex-grow: 1;
justify-content: flex-end;
> * {
margin-left: 16px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.stop {
width: 32px;
height: 32px;
background: $light;
border-radius: 50%;
> * {
width: 20px;
height: 20px;
}
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.5s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(-64px);
}
}
}
@media screen and (max-width: $max-width) {
.global-controls {
margin: 16px auto -16px;
}
}
</style>

View File

@@ -0,0 +1,46 @@
<template>
<div class="home">
<TheCategoryBar @select="select" />
<GlobalTaskControls />
<TheTaskList :selectedCategory="selectedCategory" />
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
import TheCategoryBar from '@/components/TheCategoryBar.vue';
import GlobalTaskControls from './GlobalTaskControls.vue';
import TheTaskList from './TheTaskList.vue';
export default {
name: 'Home',
components: {
TheCategoryBar,
GlobalTaskControls,
TheTaskList,
},
data() {
return {
selectedCategory: undefined,
};
},
computed: {
...mapState(['midnightReset', 'lastReset']),
},
beforeMount() {
const lastMidnight = new Date();
lastMidnight.setHours(0, 0, 0, 0);
const lastReset = new Date(this.lastReset);
if (this.midnightReset && lastReset < lastMidnight) {
this.resetTasks();
}
},
methods: {
...mapMutations(['resetTasks']),
select(category) {
this.selectedCategory = category;
},
},
};
</script>

View File

@@ -0,0 +1,55 @@
<template>
<Modal ref="modal">
<div class="title">Are you sure you want to delete all of your tasks?</div>
<div class="buttons">
<button @click="close">
Cancel
</button>
<button @click="removeAllTasksAndClose">
Delete
</button>
</div>
</Modal>
</template>
<script>
import { mapMutations } from 'vuex';
import Modal from '@/components/Modal.vue';
export default {
components: {
Modal,
},
methods: {
...mapMutations(['removeAllTasks']),
removeAllTasksAndClose() {
this.removeAllTasks();
this.close();
},
open() {
this.$refs.modal.open();
},
close() {
this.$refs.modal.close();
},
},
};
</script>
<style lang="scss" scoped>
.title {
margin-right: 32px;
}
.buttons {
margin-top: 16px;
display: flex;
justify-content: space-evenly;
> * {
padding: 6px 12px;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<Modal ref="modal">
<div class="title">Are you sure you want to reset all of your tasks to 0?</div>
<div class="buttons">
<button @click="close">
Cancel
</button>
<button @click="resetTasksAndClose">
Reset
</button>
</div>
</Modal>
</template>
<script>
import { mapMutations } from 'vuex';
import Modal from '@/components/Modal.vue';
export default {
components: {
Modal,
},
methods: {
...mapMutations(['resetTasks']),
resetTasksAndClose() {
this.resetTasks();
this.close();
},
open() {
this.$refs.modal.open();
},
close() {
this.$refs.modal.close();
},
},
};
</script>
<style lang="scss" scoped>
.title {
margin-right: 32px;
}
.buttons {
margin-top: 16px;
display: flex;
justify-content: space-evenly;
> * {
padding: 6px 12px;
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="task">
<Time
:time="time"
:pulsing="task.running"
/>
<div class="toggle-state">
<div v-if="task.running" @click="stopTask">
<img src="@/assets/pause.svg" />
</div>
<div v-else @click="startTask">
<img src="@/assets/play.svg" />
</div>
</div>
<div class="optional-break">
<div class="name">
{{ task.name }}
</div>
<div
v-if="task.category"
class="category"
:style="{ background: stringToColor(task.category) }"
>
{{ task.category }}
<span @click="removeCategory"><img src="@/assets/cross.svg" /></span>
</div>
<div v-else class="select-category" @click="$refs.categorySelect.open">
Assign Category
</div>
</div>
<CategoryModal
ref="categorySelect"
:name="task.name"
/>
<div class="delete" @click="removeTask">
<img src="@/assets/trash.svg" />
</div>
</div>
</template>
<script>
import toColor from '@/stringToColor';
import Time from './Time.vue';
import CategoryModal from './CategoryModal.vue';
export default {
components: {
Time,
CategoryModal,
},
props: {
task: Object,
},
data() {
return {
timePassed: 0,
openDropdown: false,
};
},
methods: {
startTask() {
this.$store.commit('startTask', this.task.name);
},
stopTask() {
this.$store.commit('stopTask', this.task.name);
},
removeCategory() {
this.$store.commit('assignCategory', {
name: this.task.name,
category: undefined,
});
},
stringToColor(str) {
return toColor(str);
},
removeTask() {
this.$store.commit('removeTask', this.task.name);
},
},
computed: {
time() {
if (this.task.startedAt) {
return this.task.totalTime + this.timePassed;
}
return this.task.totalTime;
},
},
beforeMount() {
setInterval(() => {
this.timePassed = Date.now() - this.task.startedAt;
}, 1000);
},
};
</script>
<style lang="scss" scoped>
.task {
color: $lighter;
min-height: 42px;
display: flex;
align-items: center;
.time {
min-width: 104px;
}
.toggle-state {
margin-right: 16px;
> * {
width: 32px;
height: 32px;
background: $light;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
> * {
width: 20px;
height: 20px;
}
}
}
.optional-break {
flex-grow: 1;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
align-items: center;
}
.select-category {
position: relative;
color: $light;
background: $dark;
padding: 4px 12px;
border-radius: 16px;
cursor: pointer;
}
.delete {
margin-left: 16px;
cursor: pointer;
}
}
@media screen and (max-width: $max-width) {
.task {
min-height: 64px;
margin-bottom: 16px;
.optional-break {
flex-direction: column;
height: 48px;
}
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="task-list">
<div>
<input
class="add-task"
v-model=newTask
placeholder="Add new task"
@keypress.enter="addTask"
/>
</div>
<Task
v-for="task in filteredTasks"
:key="task.startedAt"
:task=task
/>
</div>
</template>
<script>
import { mapState } from 'vuex';
import Task from './Task.vue';
export default {
components: {
Task,
},
props: {
selectedCategory: String,
},
data() {
return {
newTask: '',
};
},
methods: {
addTask() {
this.$store.commit('addTask', this.newTask);
this.newTask = '';
},
},
computed: {
...mapState(['tasks']),
filteredTasks() {
if (this.selectedCategory) {
return this.tasks.filter((task) => task.category === this.selectedCategory);
}
return this.tasks;
},
},
};
</script>
<style lang="scss" scoped>
.task-list {
max-width: $max-width;
margin: 32px auto;
padding: 0 8px;
.add-task {
width: 100%;
background: $light;
color: $darker;
padding: 4px 12px;
height: 32px;
box-sizing: border-box;
margin-bottom: 16px;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div
class="time"
:class="{ pulsing }"
>
{{ formattedTime }}
</div>
</template>
<script>
export default {
props: {
time: {
type: Number,
required: true,
},
pulsing: {
type: Boolean,
default: false,
},
},
computed: {
formattedTime() {
let { time } = this;
const msInASec = 1000;
const msInAMin = msInASec * 60;
const msInAHour = msInAMin * 60;
const msInADay = msInAHour * 24;
const days = Math.floor(time / msInADay);
time -= days * msInADay;
const hours = Math.floor(time / msInAHour);
time -= hours * msInAHour;
const mins = Math.floor(time / msInAMin);
return `${days}d ${hours}h ${mins}m`;
},
},
};
</script>
<style lang="scss" scoped>
@keyframes pulse {
from {
opacity: 0.3;
}
to {
opacity: 1;
}
}
.pulsing {
animation: pulse 1s infinite alternate;
}
</style>

23
frontend/vue.config.js Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
publicPath: process.env.NODE_ENV === 'production'
? '/worktime/'
: '/',
pwa: {
appleMobileWebAppCapable: 'yes',
appleMobileWebAppStatusBarStyle: 'black-transculent',
themeColor: '#252525',
workboxOptions: {
skipWaiting: true,
clientsClaim: true,
},
},
css: {
loaderOptions: {
sass: {
prependData: `
@import "@/scss/_variables.scss";
`,
},
},
},
};