Add intro/part1

This commit is contained in:
Richard Feldman
2018-08-09 01:32:05 -04:00
parent d879bd17cd
commit c2ce139c69
47 changed files with 11331 additions and 0 deletions

6
intro/server/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.DS_Store
node_modules/
coverage/
npm-debug.log
stats.json
yarn-error.log

21
intro/server/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Ice Services
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,6 @@
{"title":"this is my title","description":"this is just a test","body":"Hello World!","tagList":[],"slug":"this-is-my-title-ladzo0","author":"gsZdyqSGbQscoIU6","createdAt":{"$$date":1525546112100},"updatedAt":{"$$date":1525546112100},"_id":"9kCKBGvHDiWaDSjj"}
{"title":"Elm is fun!","description":"Programming","body":"I've really been enjoying it!","tagList":["elm","fun"],"slug":"elm-is-fun--zb6nba","author":"Z1YiwpVIz2GQQ13Q","createdAt":{"$$date":1525523441276},"updatedAt":{"$$date":1525523455652},"_id":"AfnCDbXe6wi8Vg8C"}
{"title":"Who says undefined isn't a function anyway?","description":"Functions","body":"Quite frankly I think undefined can be anything it wants to be, if it believes in itself.","tagList":["programming"],"slug":"who-says-undefined-isnt-a-function-anyway-t39ope","author":"Z1YiwpVIz2GQQ13Q","createdAt":{"$$date":1525523547620},"updatedAt":{"$$date":1525523547620},"_id":"CDCDlBclmwWpWdCX"}
{"title":"Are dragons real?","description":"dragons","body":"Do Komodo Dragons count? I think they should. It's right there in the name!","tagList":["dragons"],"slug":"are-dragons-real-467lsh","author":"Z1YiwpVIz2GQQ13Q","createdAt":{"$$date":1525523750396},"updatedAt":{"$$date":1525523750396},"_id":"I2h7s1VuXciCP5nl"}
{"title":"Hello World","description":"stuff","body":"this is not port 8000","tagList":[],"slug":"hello-world-dwkkns","author":"gsZdyqSGbQscoIU6","createdAt":{"$$date":1525547607765},"updatedAt":{"$$date":1525547607765},"_id":"fiusiOt77z4zbKe8"}
{"title":"This compiler is pretty neat","description":"Elm","body":"It tells me about problems in my code. How neat is that?","tagList":["compilers","elm"],"slug":"this-compiler-is-pretty-neat-9ycui8","author":"Z1YiwpVIz2GQQ13Q","createdAt":{"$$date":1525523694805},"updatedAt":{"$$date":1525523694805},"_id":"ttAbrJu6OrOJ7jAf"}

View File

View File

View File

@@ -0,0 +1 @@
{"follow":"Z1YiwpVIz2GQQ13Q","user":"gsZdyqSGbQscoIU6","createdAt":{"$$date":1525550850919},"_id":"f8Yep9EguoPa8Rwn"}

View File

@@ -0,0 +1,3 @@
{"username":"rtfeldman","email":"richard.t.feldman@gmail.com","password":"$2a$10$4WPM2GSk3pH8COW1UVAVEeu2OC7/CrCzjR91PcPZIw/Ycdvyg3Aqi","bio":"","image":null,"createdAt":{"$$date":1533459071196},"_id":"7r8Pdo5csPGElToj"}
{"username":"SamSample","email":"sam@sample.com","password":"samsample","bio":"I'm the sample user for the workshop. Hi!","image":"https://user-images.githubusercontent.com/1094080/39663282-6459c64e-503e-11e8-8da8-a2af2c81d052.png","createdAt":{"$$date":1525523183044},"_id":"Z1YiwpVIz2GQQ13Q","updatedAt":{"$$date":1525523378471}}
{"username":"wefjbsfdjkhdgrskjhsdfgjhb","email":"sdfgjhsgjhbdfgkjhsdf@sfgdhjfjhgdfgf.com","password":"$2a$10$gnFdddybmitDP.yKM3OjfubgZ1O3geK9N8LymFV4mZYaSGe9WYPby","bio":"","image":null,"createdAt":{"$$date":1525546056406},"_id":"gsZdyqSGbQscoIU6"}

32
intro/server/elm.json Normal file
View File

@@ -0,0 +1,32 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.0",
"dependencies": {
"direct": {
"NoRedInk/json-decode-pipeline": "1.0.0",
"elm/browser": "1.0.0",
"elm/core": "1.0.0",
"elm/html": "1.0.0",
"elm/http": "1.0.0",
"elm/json": "1.0.0",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm-explorations/markdown": "1.0.0",
"lukewestby/elm-http-builder": "6.0.0",
"rtfeldman/elm-iso8601": "1.0.1",
"rtfeldman/elm-validate": "4.0.0"
},
"indirect": {
"elm/parser": "1.0.0",
"elm/regex": "1.0.0",
"elm/virtual-dom": "1.0.0"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

BIN
intro/server/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,31 @@
"use strict";
const path = require("path");
const mkdir = require("mkdirp").sync;
const DbService = require("moleculer-db");
//process.env.MONGO_URI = "mongodb://localhost/conduit";
module.exports = function(collection) {
if (process.env.MONGO_URI) {
// Mongo adapter
const MongoAdapter = require("moleculer-db-adapter-mongo");
return {
mixins: [DbService],
adapter: new MongoAdapter(process.env.MONGO_URI),
collection
};
}
// --- NeDB fallback DB adapter
// Create data folder
mkdir(path.resolve("./data"));
return {
mixins: [DbService],
adapter: new DbService.MemoryAdapter({ filename: `./data/${collection}.db` })
};
};

View File

@@ -0,0 +1,15 @@
"use strict";
const os = require("os");
module.exports = {
// It will be unique when scale up instances in Docker or on local computer
nodeID: os.hostname().toLowerCase() + "-" + process.pid,
logger: false,
logLevel: "info",
cacher: "memory",
metrics: false
};

8509
intro/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
intro/server/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "moleculer-realworld-example-app",
"version": "1.0.0",
"description": "RealWorld example app with Moleculer microservices framework",
"scripts": {
"start": "moleculer-runner services"
},
"keywords": [
"microservices",
"moleculer",
"realworld"
],
"author": "",
"license": "MIT",
"devDependencies": {
"eslint": "4.11.0",
"jest": "21.2.1",
"jest-cli": "21.2.1",
"moleculer-repl": "^0.3.0",
"npm-check": "5.5.2"
},
"dependencies": {
"bcrypt-nodejs": "0.0.3",
"jsonwebtoken": "8.1.0",
"lodash": "4.17.4",
"moleculer": "^0.11.0",
"moleculer-db": "0.7.0",
"moleculer-db-adapter-mongo": "0.1.6",
"moleculer-web": "0.6.0-beta7",
"nats": "0.7.24",
"slug": "0.9.1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ice-services/moleculer-realworld-example-app.git"
},
"engines": {
"node": ">= 6.x.x"
},
"jest": {
"testEnvironment": "node",
"rootDir": "./services",
"roots": [
"../test"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,4 @@
{
"main.js": "static/js/main.cd99036f.js",
"main.js.map": "static/js/main.cd99036f.js.map"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,528 @@
/* latin-ext */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 400;
src: local('Merriweather Sans'), local('MerriweatherSans-Regular'), url(http://fonts.gstatic.com/s/merriweathersans/v9/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX7QjXp8Bte9ZM.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 400;
src: local('Merriweather Sans'), local('MerriweatherSans-Regular'), url(http://fonts.gstatic.com/s/merriweathersans/v9/2-c99IRs1JiJN1FRAMjTN5zd9vgsFHX1QjXp8Bte.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 700;
src: local('Merriweather Sans Bold'), local('MerriweatherSans-Bold'), url(http://fonts.gstatic.com/s/merriweathersans/v9/2-c49IRs1JiJN1FRAMjTN5zd9vgsFH1OZyDK0hZ0z5qZUqw.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Merriweather Sans';
font-style: normal;
font-weight: 700;
src: local('Merriweather Sans Bold'), local('MerriweatherSans-Bold'), url(http://fonts.gstatic.com/s/merriweathersans/v9/2-c49IRs1JiJN1FRAMjTN5zd9vgsFH1OZyDE0hZ0z5qZ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidh18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkido18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidg18S0xR41YDw.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidv18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidj18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkidi18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 300;
src: local('Source Sans Pro Light Italic'), local('SourceSansPro-LightItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZMkids18S0xR41.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7qsDJB9cme_xc.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7jsDJB9cme_xc.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7rsDJB9cme_xc.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7ksDJB9cme_xc.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7osDJB9cme_xc.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7psDJB9cme_xc.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 400;
src: local('Source Sans Pro Italic'), local('SourceSansPro-Italic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK1dSBYKcSV-LCoeQqfX1RYOo3qPZ7nsDJB9cme.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdh18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdo18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdg18S0xR41YDw.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdv18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdj18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCdi18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 600;
src: local('Source Sans Pro SemiBold Italic'), local('SourceSansPro-SemiBoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZY4lCds18S0xR41.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdh18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdo18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdg18S0xR41YDw.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdv18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdj18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSdi18S0xR41YDw.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: italic;
font-weight: 700;
src: local('Source Sans Pro Bold Italic'), local('SourceSansPro-BoldItalic'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKwdSBYKcSV-LCoeQqfX1RYOo3qPZZclSds18S0xR41.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmhdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwkxdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmxdu3cOWxy40.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlBdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmBdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwmRdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 300;
src: local('Source Sans Pro Light'), local('SourceSansPro-Light'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ik4zwlxdu3cOWxw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNa7lujVj9_mf.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qPK7lujVj9_mf.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNK7lujVj9_mf.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qO67lujVj9_mf.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qN67lujVj9_mf.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qNq7lujVj9_mf.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 400;
src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xK3dSBYKcSV-LCoeQqfX1RYOo3qOK7lujVj9w.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmhdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwkxdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmxdu3cOWxy40.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlBdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmBdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwmRdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 600;
src: local('Source Sans Pro SemiBold'), local('SourceSansPro-SemiBold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3i54rwlxdu3cOWxw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmhdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwkxdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmxdu3cOWxy40.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlBdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmBdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwmRdu3cOWxy40.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Sans Pro';
font-style: normal;
font-weight: 700;
src: local('Source Sans Pro Bold'), local('SourceSansPro-Bold'), url(http://fonts.gstatic.com/s/sourcesanspro/v11/6xKydSBYKcSV-LCoeQqfX1RYOo3ig4vwlxdu3cOWxw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: local('Source Serif Pro'), local('SourceSerifPro-Regular'), url(http://fonts.gstatic.com/s/sourceserifpro/v5/neIQzD-0qpwxpaWvjeD0X88SAOeauXo-oAGIyY0Wfw.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 400;
src: local('Source Serif Pro'), local('SourceSerifPro-Regular'), url(http://fonts.gstatic.com/s/sourceserifpro/v5/neIQzD-0qpwxpaWvjeD0X88SAOeauXQ-oAGIyY0.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: local('Source Serif Pro Bold'), local('SourceSerifPro-Bold'), url(http://fonts.gstatic.com/s/sourceserifpro/v5/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSKqxKcsdrOPbQ.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Serif Pro';
font-style: normal;
font-weight: 700;
src: local('Source Serif Pro Bold'), local('SourceSerifPro-Bold'), url(http://fonts.gstatic.com/s/sourceserifpro/v5/neIXzD-0qpwxpaWvjeD0X88SAOeasc8btSyqxKcsdrM.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 700;
src: local('Titillium Web Bold'), local('TitilliumWeb-Bold'), url(http://fonts.gstatic.com/s/titilliumweb/v6/NaPDcZTIAOhVxoMyOr9n_E7ffHjDGIVzY5abuWIGxA.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 700;
src: local('Titillium Web Bold'), local('TitilliumWeb-Bold'), url(http://fonts.gstatic.com/s/titilliumweb/v6/NaPDcZTIAOhVxoMyOr9n_E7ffHjDGItzY5abuWI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Elm Workshop</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<!-- Import Ionicon icons & Google Fonts our Bootstrap theme relies on -->
<link href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css" rel="stylesheet" type="text/css">
<link href="/fonts.css" rel="stylesheet" type="text/css">
<!-- Import the custom Bootstrap 4 theme from our hosted CDN -->
<link rel="stylesheet" href="/main.css">
<style>/* Loading spinner courtesy of https://github.com/tobiasahlin/SpinKit */
.sk-three-bounce { margin-right: 10px; } .sk-three-bounce .sk-child { width: 14px; height: 14px; background-color: #333; border-radius: 100%; display: inline-block; -webkit-animation: sk-three-bounce 1.4s ease-in-out 0s infinite both; animation: sk-three-bounce 1.4s ease-in-out 0s infinite both; } .sk-three-bounce .sk-bounce1 { -webkit-animation-delay: 0.28s; animation-delay: 0.28s; } .sk-three-bounce .sk-bounce2 { -webkit-animation-delay: 0.44s; animation-delay: 0.44s; } .sk-three-bounce .sk-bounce3 { -webkit-animation-delay: 0.6s; animation-delay: 0.6s; } @-webkit-keyframes sk-three-bounce { 0%, 80%, 100% { -webkit-transform: scale(0); transform: scale(0); } 40% { -webkit-transform: scale(1); transform: scale(1); } } @keyframes sk-three-bounce { 0%, 80%, 100% { -webkit-transform: scale(0); transform: scale(0); } 40% { -webkit-transform: scale(1); transform: scale(1); } }
</style>
<script src="/elm.js"></script>
</head>
<body>
<script>
var app = Elm.Main.init({flags: localStorage.session || null});
app.ports.storeSession.subscribe(function(session) {
localStorage.session = session;
});
window.addEventListener("storage", function(event) {
if (event.storageArea === localStorage && event.key === "session") {
app.ports.onSessionChange.send(event.newValue);
}
}, false);
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M235 6959 c22 -22 365 -365 761 -762 l721 -722 1652 0 c908 -1 1651
2 1651 5 0 3 -341 346 -758 763 l-757 757 -1655 0 -1654 0 39 -41z"/>
<path d="M3900 6995 c0 -3 698 -704 1550 -1557 l1550 -1551 0 1557 0 1556
-1550 0 c-852 0 -1550 -2 -1550 -5z"/>
<path d="M0 3495 l0 -3310 1655 1655 c909 910 1653 1655 1652 1657 -5 5 -3235
3237 -3269 3270 l-38 37 0 -3309z"/>
<path d="M2008 5199 c-5 -3 329 -345 743 -758 l752 -752 753 753 c414 414 751
755 748 757 -6 7 -2985 7 -2996 0z"/>
<path d="M4522 4327 c-452 -453 -822 -827 -822 -831 0 -4 369 -376 821 -828
l821 -821 829 829 829 829 -822 822 c-453 453 -825 823 -828 823 -3 0 -375
-370 -828 -823z"/>
<path d="M1853 1655 c-904 -905 -1643 -1647 -1643 -1650 0 -3 1484 -5 3297 -5
l3298 0 -1650 1650 c-907 908 -1652 1650 -1655 1650 -3 0 -744 -741 -1647
-1645z"/>
<path d="M6265 2384 c-396 -398 -722 -726 -724 -728 -2 -3 326 -335 728 -737
l731 -732 0 1461 c0 804 -3 1462 -7 1461 -5 -1 -332 -327 -728 -725z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

BIN
intro/server/rw-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,171 @@
"use strict";
const _ = require("lodash");
const ApiGateway = require("moleculer-web");
const { UnAuthorizedError } = ApiGateway.Errors;
module.exports = {
name: "api",
mixins: [ApiGateway],
settings: {
port: process.env.PORT || 3000,
routes: [{
path: "/api",
authorization: true,
aliases: {
// Login
"POST /users/login": "users.login",
// Users
"REST /users": "users",
// Current user
"GET /user": "users.me",
"PUT /user": "users.updateMyself",
// Articles
"GET /articles/feed": "articles.feed",
"REST /articles": "articles",
"GET /tags": "articles.tags",
// Comments
"GET /articles/:slug/comments": "articles.comments",
"POST /articles/:slug/comments": "articles.addComment",
"PUT /articles/:slug/comments/:commentID": "articles.updateComment",
"DELETE /articles/:slug/comments/:commentID": "articles.removeComment",
// Favorites
"POST /articles/:slug/favorite": "articles.favorite",
"DELETE /articles/:slug/favorite": "articles.unfavorite",
// Profile
"GET /profiles/:username": "users.profile",
"POST /profiles/:username/follow": "users.follow",
"DELETE /profiles/:username/follow": "users.unfollow",
},
// Disable to call not-mapped actions
mappingPolicy: "restrict",
// Set CORS headers
cors: true,
// Parse body content
bodyParsers: {
json: {
strict: false
},
urlencoded: {
extended: false
}
}
}],
assets: {
folder: "./public"
},
// logRequestParams: "info",
// logResponseData: "info",
onError(req, res, err) {
// Return with the error as JSON object
res.setHeader("Content-type", "application/json; charset=utf-8");
res.writeHead(err.code || 500);
if (err.code == 422) {
let o = {};
err.data.forEach(e => {
let field = e.field.split(".").pop();
o[field] = e.message;
});
res.end(JSON.stringify({ errors: o }, null, 2));
} else {
const errObj = _.pick(err, ["name", "message", "code", "type", "data"]);
res.end(JSON.stringify(errObj, null, 2));
}
this.logResponse(req, res, err? err.ctx : null);
}
},
methods: {
/**
* Authorize the request
*
* @param {Context} ctx
* @param {Object} route
* @param {IncomingRequest} req
* @returns {Promise}
*/
authorize(ctx, route, req) {
let token;
if (req.headers.authorization) {
let type = req.headers.authorization.split(" ")[0];
if (type === "Token" || type === "Bearer")
token = req.headers.authorization.split(" ")[1];
}
return this.Promise.resolve(token)
.then(token => {
if (token) {
// Verify JWT token
return ctx.call("users.resolveToken", { token })
.then(user => {
if (user) {
this.logger.info("Authenticated via JWT: ", user.username);
// Reduce user fields (it will be transferred to other nodes)
ctx.meta.user = _.pick(user, ["_id", "username", "email", "image"]);
ctx.meta.token = token;
}
return user;
})
.catch(err => {
// Ignored because we continue processing if user is not exist
return null;
});
}
})
.then(user => {
if (req.$endpoint.action.auth == "required" && !user)
return this.Promise.reject(new UnAuthorizedError());
});
},
/**
* Convert ValidationError to RealWorld.io result
* @param {*} req
* @param {*} res
* @param {*} err
*/
/*sendError(req, res, err) {
if (err.code == 422) {
res.setHeader("Content-type", "application/json; charset=utf-8");
res.writeHead(422);
let o = {};
err.data.forEach(e => {
let field = e.field.split(".").pop();
o[field] = e.message;
});
return res.end(JSON.stringify({
errors: o
}, null, 2));
}
return this._sendError(req, res, err);
}*/
},
created() {
// Pointer to the original function
//this._sendError = ApiGateway.methods.sendError.bind(this);
}
};

View File

@@ -0,0 +1,614 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
const { ForbiddenError } = require("moleculer-web").Errors;
const _ = require("lodash");
const slug = require("slug");
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "articles",
mixins: [DbService("articles")],
/**
* Default settings
*/
settings: {
fields: ["_id", "title", "slug", "description", "body", "tagList", "createdAt", "updatedAt", "favorited", "favoritesCount", "author", "comments"],
// Populates
populates: {
author: {
action: "users.get",
params: {
fields: ["username", "bio", "image"]
}
},
comments: {
action: "comments.get",
params: {
fields: ["_id", "body", "author"],
populates: ["author"]
}
},
favorited(ids, articles, rule, ctx) {
if (ctx.meta.user)
return this.Promise.all(articles.map(article => ctx.call("favorites.has", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(res => article.favorited = res)));
else {
articles.forEach(article => article.favorited = false);
return this.Promise.resolve();
}
},
favoritesCount(ids, articles, rule, ctx) {
return this.Promise.all(articles.map(article => ctx.call("favorites.count", { article: article._id.toString() }).then(count => article.favoritesCount = count)));
}
},
// Validation schema for new entities
entityValidator: {
title: { type: "string", min: 1 },
description: { type: "string", min: 1 },
body: { type: "string", min: 1 },
tagList: { type: "array", items: "string", optional: true },
}
},
/**
* Actions
*/
actions: {
/**
* Create a new article.
* Auth is required!
*
* @actions
* @param {Object} article - Article entity
*
* @returns {Object} Created entity
*/
create: {
auth: "required",
params: {
article: { type: "object" }
},
handler(ctx) {
let entity = ctx.params.article;
return this.validateEntity(entity)
.then(() => {
entity.slug = slug(entity.title, { lower: true }) + "-" + (Math.random() * Math.pow(36, 6) | 0).toString(36);
entity.author = ctx.meta.user._id.toString();
entity.createdAt = new Date();
entity.updatedAt = new Date();
return this.adapter.insert(entity)
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Update an article.
* Auth is required!
*
* @actions
* @param {String} id - Article ID
* @param {Object} article - Article modified fields
*
* @returns {Object} Updated entity
*/
update: {
auth: "required",
params: {
id: { type: "string" },
article: { type: "object", props: {
title: { type: "string", min: 1, optional: true },
description: { type: "string", min: 1, optional: true },
body: { type: "string", min: 1, optional: true },
tagList: { type: "array", items: "string", optional: true },
} }
},
handler(ctx) {
let newData = ctx.params.article;
newData.updatedAt = new Date();
// the 'id' is the slug
return this.Promise.resolve(ctx.params.id)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
if (article.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
const update = {
"$set": newData
};
return this.adapter.updateById(article._id, update);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
}
},
/**
* List articles with pagination.
*
* @actions
* @param {String} tag - Filter for 'tag'
* @param {String} author - Filter for author ID
* @param {String} favorited - Filter for favorited author
* @param {Number} limit - Pagination limit
* @param {Number} offset - Pagination offset
*
* @returns {Object} List of articles
*/
list: {
cache: {
keys: ["#token", "tag", "author", "favorited", "limit", "offset"]
},
params: {
tag: { type: "string", optional: true },
author: { type: "string", optional: true },
favorited: { type: "string", optional: true },
limit: { type: "number", optional: true, convert: true },
offset: { type: "number", optional: true, convert: true },
},
handler(ctx) {
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
let params = {
limit,
offset,
sort: ["-createdAt"],
populate: ["author", "favorited", "favoritesCount"],
query: {}
};
let countParams;
if (ctx.params.tag)
params.query.tagList = {"$in" : [ctx.params.tag]};
return this.Promise.resolve()
.then(() => {
if (ctx.params.author) {
return ctx.call("users.find", { query: { username: ctx.params.author } })
.then(users => {
if (users.length == 0)
return this.Promise.reject(new MoleculerClientError("Author not found"));
params.query.author = users[0]._id;
});
}
if (ctx.params.favorited) {
return ctx.call("users.find", { query: { username: ctx.params.favorited } })
.then(users => {
if (users.length == 0)
return this.Promise.reject(new MoleculerClientError("Author not found"));
return users[0]._id;
})
.then(user => {
return ctx.call("favorites.find", { fields: ["article"], query: { user }})
.then(list => {
params.query._id = { $in: list.map(o => o.article) };
});
});
}
})
.then(() => {
countParams = Object.assign({}, params);
// Remove pagination params
if (countParams && countParams.limit)
countParams.limit = null;
if (countParams && countParams.offset)
countParams.offset = null;
})
.then(() => this.Promise.all([
// Get rows
this.adapter.find(params),
// Get count of all rows
this.adapter.count(countParams)
])).then(res => {
return this.transformDocuments(ctx, params, res[0])
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
.then(r => {
r.articlesCount = res[1];
return r;
});
});
}
},
/**
* List articles from followed authors.
* Auth is required!
*
* @actions
* @param {Number} limit - Pagination limit
* @param {Number} offset - Pagination offset
*
* @returns {Object} List of articles
*/
feed: {
auth: "required",
cache: {
keys: ["#token", "limit", "offset"]
},
params: {
limit: { type: "number", optional: true, convert: true },
offset: { type: "number", optional: true, convert: true },
},
handler(ctx) {
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
let params = {
limit,
offset,
sort: ["-createdAt"],
populate: ["author", "favorited", "favoritesCount"],
query: {}
};
let countParams;
return this.Promise.resolve()
.then(() => {
return ctx.call("follows.find", { fields: ["follow"], query: { user: ctx.meta.user._id.toString() } })
.then(list => {
const authors = _.uniq(_.compact(_.flattenDeep(list.map(o => o.follow))));
params.query.author = {"$in" : authors};
});
})
.then(() => {
countParams = Object.assign({}, params);
// Remove pagination params
if (countParams && countParams.limit)
countParams.limit = null;
if (countParams && countParams.offset)
countParams.offset = null;
})
.then(() => this.Promise.all([
// Get rows
this.adapter.find(params),
// Get count of all rows
this.adapter.count(countParams)
])).then(res => {
return this.transformDocuments(ctx, params, res[0])
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
.then(r => {
r.articlesCount = res[1];
return r;
});
});
}
},
/**
* Get an article by slug
*
* @actions
* @param {String} id - Article slug
*
* @returns {Object} Article entity
*/
get: {
cache: {
keys: ["#token", "id"]
},
params: {
id: { type: "string" }
},
handler(ctx) {
return this.findBySlug(ctx.params.id)
.then(entity => {
if (!entity)
return this.Promise.reject(new MoleculerClientError("Article not found!", 404));
return entity;
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
}
},
/**
* Remove an article by slug
* Auth is required!
*
* @actions
* @param {String} id - Article slug
*
* @returns {Number} Count of removed articles
*/
remove: {
auth: "required",
params: {
id: { type: "any" }
},
handler(ctx) {
return this.findBySlug(ctx.params.id)
.then(entity => {
if (!entity)
return this.Promise.reject(new MoleculerClientError("Article not found!", 404));
if (entity.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
return this.adapter.removeById(entity._id)
.then(() => ctx.call("favorites.removeByArticle", { article: entity._id }))
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
});
}
},
/**
* Favorite an article
* Auth is required!
*
* @actions
* @param {String} id - Article slug
*
* @returns {Object} Updated article
*/
favorite: {
auth: "required",
params: {
slug: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("favorites.add", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(() => article);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
}
},
/**
* Unfavorite an article
* Auth is required!
*
* @actions
* @param {String} id - Article slug
*
* @returns {Object} Updated article
*/
unfavorite: {
auth: "required",
params: {
slug: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("favorites.delete", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(() => article);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
}
},
/**
* Get list of available tags
*
* @returns {Object} Tag list
*/
tags: {
cache: {
keys: []
},
handler(ctx) {
return this.Promise.resolve()
.then(() => this.adapter.find({ fields: ["tagList"], sort: ["createdAt"] }))
.then(list => {
return _.uniq(_.compact(_.flattenDeep(list.map(o => o.tagList))));
})
.then(tags => ({ tags }));
}
},
/**
* Get all comments of an article.
*
* @actions
* @param {String} slug - Article slug
*
* @returns {Object} Comment list
*
*/
comments: {
cache: {
keys: ["#token", "slug"]
},
params: {
slug: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("comments.list", { article: article._id.toString() });
});
}
},
/**
* Add a new comment to an article.
* Auth is required!
*
* @actions
* @param {String} slug - Article slug
* @param {Object} comment - Comment fields
*
* @returns {Object} Comment entity
*/
addComment: {
auth: "required",
params: {
slug: { type: "string" },
comment: { type: "object" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("comments.create", { article: article._id.toString(), comment: ctx.params.comment });
});
}
},
/**
* Update a comment.
* Auth is required!
*
* @actions
* @param {String} slug - Article slug
* @param {String} commentID - Comment ID
* @param {Object} comment - Comment fields
*
* @returns {Object} Comment entity
*/
updateComment: {
auth: "required",
params: {
slug: { type: "string" },
commentID: { type: "string" },
comment: { type: "object" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
return ctx.call("comments.update", { id: ctx.params.commentID, comment: ctx.params.comment });
});
}
},
/**
* Remove a comment.
* Auth is required!
*
* @actions
* @param {String} slug - Article slug
* @param {String} commentID - Comment ID
*
* @returns {Number} Count of removed comment
*/
removeComment: {
auth: "required",
params: {
slug: { type: "string" },
commentID: { type: "string" }
},
handler(ctx) {
return this.Promise.resolve(ctx.params.slug)
.then(slug => this.findBySlug(slug))
.then(article => {
if (!article)
return this.Promise.reject(new MoleculerClientError("Article not found"));
return ctx.call("comments.remove", { id: ctx.params.commentID });
});
}
}
},
/**
* Methods
*/
methods: {
/**
* Find an article by slug
*
* @param {String} slug - Article slug
*
* @results {Object} Promise<Article
*/
findBySlug(slug) {
return this.adapter.findOne({ slug });
},
/**
* Transform the result entities to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Array} entities
* @param {Object} user - Logged in user
*/
transformResult(ctx, entities, user) {
if (Array.isArray(entities)) {
return this.Promise.map(entities, item => this.transformEntity(ctx, item, user))
.then(articles => ({ articles }));
} else {
return this.transformEntity(ctx, entities, user)
.then(article => ({ article }));
}
},
/**
* Transform a result entity to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Object} entity
* @param {Object} user - Logged in user
*/
transformEntity(ctx, entity, user) {
if (!entity) return this.Promise.resolve();
return this.Promise.resolve(entity);
}
},
events: {
"cache.clean.articles"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.comments"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.favorites"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,266 @@
"use strict";
const { ForbiddenError } = require("moleculer-web").Errors;
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "comments",
mixins: [DbService("comments")],
/**
* Default settings
*/
settings: {
fields: ["_id", "author", "article", "body", "createdAt", "updatedAt"],
populates: {
"author": {
action: "users.get",
params: {
fields: ["_id", "username", "bio", "image"]
}
}
},
entityValidator: {
article: { type: "string" },
body: { type: "string", min: 1 },
}
},
/**
* Actions
*/
actions: {
/**
* Create a comment.
* Auth is required!
*
* @actions
* @param {String} article - Article ID
* @param {Object} comment - Comment entity
*
* @returns {Object} Created comment entity
*/
create: {
auth: "required",
params: {
article: { type: "string" },
comment: { type: "object" }
},
handler(ctx) {
let entity = ctx.params.comment;
entity.article = ctx.params.article;
entity.author = ctx.meta.user._id.toString();
return this.validateEntity(entity)
.then(() => {
entity.createdAt = new Date();
entity.updatedAt = new Date();
return this.adapter.insert(entity)
.then(doc => this.transformDocuments(ctx, { populate: ["author"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Update a comment.
* Auth is required!
*
* @actions
* @param {String} id - Comment ID
* @param {Object} comment - Comment modified fields
*
* @returns {Object} Updated comment entity
*/
update: {
auth: "required",
params: {
id: { type: "string" },
comment: { type: "object", props: {
body: { type: "string", min: 1 },
} }
},
handler(ctx) {
let newData = ctx.params.comment;
newData.updatedAt = new Date();
return this.getById(ctx.params.id)
.then(comment => {
if (comment.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
const update = {
"$set": newData
};
return this.adapter.updateById(ctx.params.id, update);
})
.then(doc => this.transformDocuments(ctx, { populate: ["author"]}, doc))
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
}
},
/**
* List of comments by article.
*
* @actions
* @param {String} article - Article ID
* @param {Number} limit - Pagination limit
* @param {Number} offset - Pagination offset
*
* @returns {Object} List of comments
*/
list: {
cache: {
keys: ["#token", "article", "limit", "offset"]
},
params: {
article: { type: "string" },
limit: { type: "number", optional: true, convert: true },
offset: { type: "number", optional: true, convert: true },
},
handler(ctx) {
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
let params = {
limit,
offset,
sort: ["-createdAt"],
populate: ["author"],
query: {
article: ctx.params.article
}
};
let countParams;
return this.Promise.resolve()
.then(() => {
countParams = Object.assign({}, params);
// Remove pagination params
if (countParams && countParams.limit)
countParams.limit = null;
if (countParams && countParams.offset)
countParams.offset = null;
})
.then(() => this.Promise.all([
// Get rows
this.adapter.find(params),
// Get count of all rows
this.adapter.count(countParams)
])).then(res => {
return this.transformDocuments(ctx, params, res[0])
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
.then(r => {
r.commentsCount = res[1];
return r;
});
});
}
},
/**
* Remove a comment
* Auth is required!
*
* @actions
* @param {String} id - Comment ID
*
* @returns {Number} Count of removed comments
*/
remove: {
auth: "required",
params: {
id: { type: "any" }
},
handler(ctx) {
return this.getById(ctx.params.id)
.then(comment => {
if (comment.author !== ctx.meta.user._id.toString())
return this.Promise.reject(new ForbiddenError());
return this.adapter.removeById(ctx.params.id)
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
});
}
}
},
/**
* Methods
*/
methods: {
/**
* Transform the result entities to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Array} entities
* @param {Object} user - Logged in user
*/
transformResult(ctx, entities, user) {
if (Array.isArray(entities)) {
return this.Promise.map(entities, item => this.transformEntity(ctx, item, user))
.then(comments => ({ comments }));
} else {
return this.transformEntity(ctx, entities, user)
.then(comment => ({ comment }));
}
},
/**
* Transform a result entity to follow the RealWorld API spec
*
* @param {Context} ctx
* @param {Object} entity
* @param {Object} user - Logged in user
*/
transformEntity(ctx, entity, loggedInUser) {
if (!entity) return this.Promise.resolve();
return this.Promise.resolve(entity)
.then(entity => {
entity.id = entity._id;
if (loggedInUser) {
return ctx.call("follows.has", { user: loggedInUser._id.toString(), follow: entity.author._id })
.then(res => {
entity.author.following = res;
return entity;
});
}
entity.author.following = false;
return entity;
});
}
},
events: {
"cache.clean.comments"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.articles"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,177 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "favorites",
mixins: [DbService("favorites")],
/**
* Default settings
*/
settings: {
},
/**
* Actions
*/
actions: {
/**
* Create a new favorite record
*
* @actions
*
* @param {String} article - Article ID
* @param {String} user - User ID
* @returns {Object} Created favorite record
*/
add: {
params: {
article: { type: "string" },
user: { type: "string" },
},
handler(ctx) {
const { article, user } = ctx.params;
return this.findByArticleAndUser(article, user)
.then(item => {
if (item)
return this.Promise.reject(new MoleculerClientError("Articles has already favorited"));
return this.adapter.insert({ article, user, createdAt: new Date() })
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Check the given 'article' is followed by 'user'.
*
* @actions
*
* @param {String} article - Article ID
* @param {String} user - User ID
* @returns {Boolean}
*/
has: {
cache: {
keys: ["article", "user"]
},
params: {
article: { type: "string" },
user: { type: "string" },
},
handler(ctx) {
const { article, user } = ctx.params;
return this.findByArticleAndUser(article, user)
.then(item => !!item);
}
},
/**
* Count of favorites.
*
* @actions
*
* @param {String?} article - Article ID
* @param {String?} user - User ID
* @returns {Number}
*/
count: {
cache: {
keys: ["article", "user"]
},
params: {
article: { type: "string", optional: true },
user: { type: "string", optional: true },
},
handler(ctx) {
let query = {};
if (ctx.params.article)
query = { article: ctx.params.article };
if (ctx.params.user)
query = { user: ctx.params.user };
return this.adapter.count({ query });
}
},
/**
* Delete a favorite record
*
* @actions
*
* @param {String} article - Article ID
* @param {String} user - User ID
* @returns {Number} Count of removed records
*/
delete: {
params: {
article: { type: "string" },
user: { type: "string" },
},
handler(ctx) {
const { article, user } = ctx.params;
return this.findByArticleAndUser(article, user)
.then(item => {
if (!item)
return this.Promise.reject(new MoleculerClientError("Articles has not favorited yet"));
return this.adapter.removeById(item._id)
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
});
}
},
/**
* Remove all favorites by article
*
* @actions
*
* @param {String} article - Article ID
* @returns {Number} Count of removed records
*/
removeByArticle: {
params: {
article: { type: "string" }
},
handler(ctx) {
return this.adapter.removeMany(ctx.params);
}
}
},
/**
* Methods
*/
methods: {
/**
* Find the first favorite record by 'article' or 'user'
* @param {String} article - Article ID
* @param {String} user - User ID
*/
findByArticleAndUser(article, user) {
return this.adapter.findOne({ article, user });
},
},
events: {
"cache.clean.favorites"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.articles"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,153 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "follows",
mixins: [DbService("follows")],
/**
* Default settings
*/
settings: {
},
/**
* Actions
*/
actions: {
/**
* Create a new following record
*
* @actions
*
* @param {String} user - Follower username
* @param {String} follow - Followee username
* @returns {Object} Created following record
*/
add: {
params: {
user: { type: "string" },
follow: { type: "string" },
},
handler(ctx) {
const { follow, user } = ctx.params;
return this.findByFollowAndUser(follow, user)
.then(item => {
if (item)
return this.Promise.reject(new MoleculerClientError("User has already followed"));
return this.adapter.insert({ follow, user, createdAt: new Date() })
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Check the given 'follow' user is followed by 'user' user.
*
* @actions
*
* @param {String} user - Follower username
* @param {String} follow - Followee username
* @returns {Boolean}
*/
has: {
cache: {
keys: ["article", "user"]
},
params: {
user: { type: "string" },
follow: { type: "string" },
},
handler(ctx) {
return this.findByFollowAndUser(ctx.params.follow, ctx.params.user)
.then(item => !!item);
}
},
/**
* Count of following.
*
* @actions
*
* @param {String?} user - Follower username
* @param {String?} follow - Followee username
* @returns {Number}
*/
count: {
cache: {
keys: ["article", "user"]
},
params: {
follow: { type: "string", optional: true },
user: { type: "string", optional: true },
},
handler(ctx) {
let query = {};
if (ctx.params.follow)
query = { follow: ctx.params.follow };
if (ctx.params.user)
query = { user: ctx.params.user };
return this.adapter.count({ query });
}
},
/**
* Delete a following record
*
* @actions
*
* @param {String} user - Follower username
* @param {String} follow - Followee username
* @returns {Number} Count of removed records
*/
delete: {
params: {
user: { type: "string" },
follow: { type: "string" },
},
handler(ctx) {
const { follow, user } = ctx.params;
return this.findByFollowAndUser(follow, user)
.then(item => {
if (!item)
return this.Promise.reject(new MoleculerClientError("User has not followed yet"));
return this.adapter.removeById(item._id)
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
});
}
}
},
/**
* Methods
*/
methods: {
/**
* Find the first following record by 'follow' or 'user'
* @param {String} follow - Followee username
* @param {String} user - Follower username
*/
findByFollowAndUser(follow, user) {
return this.adapter.findOne({ follow, user });
},
},
events: {
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

View File

@@ -0,0 +1,96 @@
let _ = require("lodash");
module.exports = {
name: "metrics",
events: {
"metrics.trace.span.start"(payload) {
this.requests[payload.id] = payload;
payload.spans = [];
if (payload.parent) {
let parent = this.requests[payload.parent];
if (parent)
parent.spans.push(payload.id);
}
},
"metrics.trace.span.finish"(payload) {
let item = this.requests[payload.id];
_.assign(item, payload);
if (!payload.parent)
this.printRequest(payload.id);
}
},
methods: {
printRequest(id) {
let main = this.requests[id];
let w = 73;
let r = _.repeat;
let gw = 35;
let maxTitle = w - 2 - 2 - gw - 2 - 1;
this.logger.info(["┌", r("─", w-2), "┐"].join(""));
let printSpanTime = (span) => {
let time = span.duration.toFixed(2);
let maxActionName = maxTitle - (span.level-1) * 2 - time.length - 3 - (span.fromCache ? 2 : 0) - (span.remoteCall ? 2 : 0) - (span.error ? 2 : 0);
let actionName = span.action ? span.action.name : "";
if (actionName.length > maxActionName)
actionName = _.truncate(span.action.name, { length: maxActionName });
let strAction = [
r(" ", span.level - 1),
actionName,
r(" ", maxActionName - actionName.length + 1),
span.fromCache ? "* " : "",
span.remoteCall ? "» " : "",
span.error ? "× " : "",
time,
"ms "
].join("");
if (span.startTime == null || span.endTime == null) {
this.logger.info(strAction + "! Missing invoke !");
return;
}
let gstart = (span.startTime - main.startTime) / (main.endTime - main.startTime) * 100;
let gstop = (span.endTime - main.startTime) / (main.endTime - main.startTime) * 100;
if (_.isNaN(gstart) && _.isNaN(gstop)) {
gstart = 0;
gstop = 100;
}
let p1 = Math.round(gw * gstart / 100);
let p2 = Math.round(gw * gstop / 100) - p1;
let p3 = Math.max(gw - (p1 + p2), 0);
let gauge = [
"[",
r(".", p1),
r("■", p2),
r(".", p3),
"]"
].join("");
this.logger.info("│ " + strAction + gauge + " │");
if (span.spans.length > 0)
span.spans.forEach(spanID => printSpanTime(this.requests[spanID]));
};
printSpanTime(main);
this.logger.info(["└", r("─", w-2), "┘"].join(""));
}
},
created() {
this.requests = {};
}
};

View File

@@ -0,0 +1,390 @@
"use strict";
const { MoleculerClientError } = require("moleculer").Errors;
//const crypto = require("crypto");
const bcrypt = require("bcrypt-nodejs");
const jwt = require("jsonwebtoken");
const DbService = require("../mixins/db.mixin");
module.exports = {
name: "users",
mixins: [DbService("users")],
/**
* Default settings
*/
settings: {
/** Secret for JWT */
JWT_SECRET: process.env.JWT_SECRET || "jwt-conduit-secret",
/** Public fields */
fields: ["_id", "username", "email", "bio", "image"],
/** Validator schema for entity */
entityValidator: {
username: { type: "string", min: 2, pattern: /^[a-zA-Z0-9]+$/ },
password: { type: "string", min: 6 },
email: { type: "email" },
bio: { type: "string", optional: true },
image: { type: "string", optional: true },
}
},
/**
* Actions
*/
actions: {
/**
* Register a new user
*
* @actions
* @param {Object} user - User entity
*
* @returns {Object} Created entity & token
*/
create: {
params: {
user: { type: "object" }
},
handler(ctx) {
let entity = ctx.params.user;
return this.validateEntity(entity)
.then(() => {
if (entity.username)
return this.adapter.findOne({ username: entity.username })
.then(found => {
if (found)
return Promise.reject(new MoleculerClientError("Username is exist!", 422, "", [{ field: "username", message: "is exist"}]));
});
})
.then(() => {
if (entity.email)
return this.adapter.findOne({ email: entity.email })
.then(found => {
if (found)
return Promise.reject(new MoleculerClientError("Email is exist!", 422, "", [{ field: "email", message: "is exist"}]));
});
})
.then(() => {
entity.password = bcrypt.hashSync(entity.password, bcrypt.genSaltSync(10));
entity.bio = entity.bio || "";
entity.image = entity.image || null;
entity.createdAt = new Date();
return this.adapter.insert(entity)
.then(doc => this.transformDocuments(ctx, {}, doc))
.then(user => this.transformEntity(user, true, ctx.meta.token))
.then(json => this.entityChanged("created", json, ctx).then(() => json));
});
}
},
/**
* Login with username & password
*
* @actions
* @param {Object} user - User credentials
*
* @returns {Object} Logged in user with token
*/
login: {
params: {
user: { type: "object", props: {
email: { type: "email" },
password: { type: "string", min: 1 }
}}
},
handler(ctx) {
const { email, password } = ctx.params.user;
return this.Promise.resolve()
.then(() => this.adapter.findOne({ email }))
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("Email or password is invalid!", 422, "", [{ field: "email", message: "is not found"}]));
return bcrypt.compare(password, user.password).then(res => {
if (!res)
return Promise.reject(new MoleculerClientError("Wrong password!", 422, "", [{ field: "email", message: "is not found"}]));
// Transform user entity (remove password and all protected fields)
return this.transformDocuments(ctx, {}, user);
});
})
.then(user => this.transformEntity(user, true, ctx.meta.token));
}
},
/**
* Get user by JWT token (for API GW authentication)
*
* @actions
* @param {String} token - JWT token
*
* @returns {Object} Resolved user
*/
resolveToken: {
cache: {
keys: ["token"],
ttl: 60 * 60 // 1 hour
},
params: {
token: "string"
},
handler(ctx) {
return new this.Promise((resolve, reject) => {
jwt.verify(ctx.params.token, this.settings.JWT_SECRET, (err, decoded) => {
if (err)
return reject(err);
resolve(decoded);
});
})
.then(decoded => {
if (decoded.id)
return this.getById(decoded.id);
});
}
},
/**
* Get current user entity.
* Auth is required!
*
* @actions
*
* @returns {Object} User entity
*/
me: {
auth: "required",
cache: {
keys: ["#token"]
},
handler(ctx) {
return this.getById(ctx.meta.user._id)
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 400));
return this.transformDocuments(ctx, {}, user);
})
.then(user => this.transformEntity(user, true, ctx.meta.token));
}
},
/**
* Update current user entity.
* Auth is required!
*
* @actions
*
* @param {Object} user - Modified fields
* @returns {Object} User entity
*/
updateMyself: {
auth: "required",
params: {
user: { type: "object", props: {
username: { type: "string", min: 2, optional: true, pattern: /^[a-zA-Z0-9]+$/ },
password: { type: "string", min: 6, optional: true },
email: { type: "email", optional: true },
bio: { type: "string", optional: true },
image: { type: "string", optional: true },
}}
},
handler(ctx) {
const newData = ctx.params.user;
return this.Promise.resolve()
.then(() => {
if (newData.username)
return this.adapter.findOne({ username: newData.username })
.then(found => {
if (found && found._id.toString() !== ctx.meta.user._id.toString())
return Promise.reject(new MoleculerClientError("Username is exist!", 422, "", [{ field: "username", message: "is exist"}]));
});
})
.then(() => {
if (newData.email)
return this.adapter.findOne({ email: newData.email })
.then(found => {
if (found && found._id.toString() !== ctx.meta.user._id.toString())
return Promise.reject(new MoleculerClientError("Email is exist!", 422, "", [{ field: "email", message: "is exist"}]));
});
})
.then(() => {
newData.updatedAt = new Date();
const update = {
"$set": newData
};
return this.adapter.updateById(ctx.meta.user._id, update);
})
.then(doc => this.transformDocuments(ctx, {}, doc))
.then(user => this.transformEntity(user, true, ctx.meta.token))
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
}
},
/**
* Get a user profile.
*
* @actions
*
* @param {String} username - Username
* @returns {Object} User entity
*/
profile: {
cache: {
keys: ["#token", "username"]
},
params: {
username: { type: "string" }
},
handler(ctx) {
return this.adapter.findOne({ username: ctx.params.username })
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
return this.transformDocuments(ctx, {}, user);
})
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
}
},
/**
* Follow a user
* Auth is required!
*
* @actions
*
* @param {String} username - Followed username
* @returns {Object} Current user entity
*/
follow: {
auth: "required",
params: {
username: { type: "string" }
},
handler(ctx) {
return this.adapter.findOne({ username: ctx.params.username })
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
return ctx.call("follows.add", { user: ctx.meta.user._id.toString(), follow: user._id.toString() })
.then(() => this.transformDocuments(ctx, {}, user));
})
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
}
},
/**
* Unfollow a user
* Auth is required!
*
* @actions
*
* @param {String} username - Unfollowed username
* @returns {Object} Current user entity
*/
unfollow: {
auth: "required",
params: {
username: { type: "string" }
},
handler(ctx) {
return this.adapter.findOne({ username: ctx.params.username })
.then(user => {
if (!user)
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
return ctx.call("follows.delete", { user: ctx.meta.user._id.toString(), follow: user._id.toString() })
.then(() => this.transformDocuments(ctx, {}, user));
})
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
}
}
},
/**
* Methods
*/
methods: {
/**
* Generate a JWT token from user entity
*
* @param {Object} user
*/
generateJWT(user) {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
return jwt.sign({
id: user._id,
username: user.username,
exp: Math.floor(exp.getTime() / 1000)
}, this.settings.JWT_SECRET);
},
/**
* Transform returned user entity. Generate JWT token if neccessary.
*
* @param {Object} user
* @param {Boolean} withToken
*/
transformEntity(user, withToken, token) {
if (user) {
//user.image = user.image || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
user.image = user.image || "";
if (withToken)
user.token = token || this.generateJWT(user);
}
return { user };
},
/**
* Transform returned user entity as profile.
*
* @param {Context} ctx
* @param {Object} user
* @param {Object?} loggedInUser
*/
transformProfile(ctx, user, loggedInUser) {
//user.image = user.image || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
user.image = user.image || "/assets/images/smiley-cyrus.jpg";
if (loggedInUser) {
return ctx.call("follows.has", { user: loggedInUser._id.toString(), follow: user._id.toString() })
.then(res => {
user.following = res;
return { profile: user };
});
}
user.following = false;
return { profile: user };
}
},
events: {
"cache.clean.users"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
},
"cache.clean.follows"() {
if (this.broker.cacher)
this.broker.cacher.clean(`${this.name}.*`);
}
}
};

62
intro/server/src/Main.elm Normal file
View File

@@ -0,0 +1,62 @@
port module Main exposing (..)
{- | Displays Youre all set! and a heart in the style of the Elm logo (created
by Marco Perone, CC-BY-SA-4.0 - thanks for sharing it, Marco!)
If this doesn't display, it means something needs to be fixed about your local
setup, and you should ask the instructor for help!
-}
import Browser
import Html exposing (Html, h1, img, section, text)
import Html.Attributes exposing (alt, src, style)
import Json.Decode exposing (Value)
port storeSession : Maybe String -> Cmd msg
port onSessionChange : (Value -> msg) -> Sub msg
main : Program () () ()
main =
Browser.application
{ init = \_ _ _ -> ( (), Cmd.none )
, onUrlChange = \_ -> ()
, onUrlRequest = \_ -> ()
, update =
\() () ->
if False then
( (), storeSession Nothing )
else
( (), Cmd.none )
, subscriptions = \() -> onSessionChange (\_ -> ())
, view =
\() ->
{ title = "Elm 0.19 workshop"
, body =
[ section
[ style "margin" "40px auto"
, style "width" "960px"
, style "text-align" "center"
]
[ h1
[ style "margin" "40px auto"
, style "font-family" "Helvetica, Arial, sans-serif"
, style "font-size" "128px"
, style "color" "rgb(90, 99, 120)"
]
[ text "Youre all set!" ]
, img
[ alt "Heart in the style of the Elm logo, by Marco Perone"
, src "https://user-images.githubusercontent.com/1094080/39399444-a90f2746-4aeb-11e8-9bd6-4fe45e535921.png"
, style "width" "368px"
, style "height" "305px"
]
[]
]
]
}
}