Deploying to gh-pages from - cc7a0f427d
🚀
This commit is contained in:
parent
07c59f6c36
commit
52fece4459
File diff suppressed because one or more lines are too long
|
@ -76,5 +76,5 @@ var r=require("./bundle-url").getBundleURL;function e(r){Array.isArray(r)||(r=[r
|
||||||
},{"./bundle-url":"FheM"}],"A3BY":[function(require,module,exports) {
|
},{"./bundle-url":"FheM"}],"A3BY":[function(require,module,exports) {
|
||||||
module.exports=function(t){return fetch(t).then(function(t){return t.text()})};
|
module.exports=function(t){return fetch(t).then(function(t){return t.text()})};
|
||||||
},{}],0:[function(require,module,exports) {
|
},{}],0:[function(require,module,exports) {
|
||||||
var b=require("TUK3");b.register("html",require("A3BY"));b.load([["quickstart.0e6b9c83.html","zjp3"],["README.a5fa9974.html","Bv5K"],["tutorial.9811de31.html","IAgs"],["ecosystem.0b358926.html","H2qL"],["sponsor.ef925587.html","c4pZ"],["guides.31d0dce1.html","XTlU"],["api.4a87837f.html","JFAn"]]).then(function(){require("Focm");});
|
var b=require("TUK3");b.register("html",require("A3BY"));b.load([["quickstart.0e6b9c83.html","zjp3"],["README.a5fa9974.html","Bv5K"],["tutorial.aa05031e.html","IAgs"],["ecosystem.0b358926.html","H2qL"],["sponsor.ef925587.html","c4pZ"],["guides.31d0dce1.html","XTlU"],["api.4a87837f.html","JFAn"]]).then(function(){require("Focm");});
|
||||||
},{}]},{},[0], null)
|
},{}]},{},[0], null)
|
|
@ -0,0 +1 @@
|
||||||
|
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"},"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"},"132":{"title":"Art is dead","author":"Kat Stropher"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"},"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"132":{"title":"Art is dead","author":"Kat Stropher"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"},"119":{"title":"My family lives on a space station","author":"Farlow Cruz"},"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"},"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"},"132":{"title":"Art is dead","author":"Kat Stropher"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"},"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"130":{"title":"Fancy art is not for me","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"113":{"title":"Ocean life is brutal","author":"Surphy McBrah"},"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"},"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"121":{"title":"I made a fortune in real-estate","author":"Richard Waller"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"116":{"title":"City running out of parking space","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"115":{"title":"Life in space confirmed","author":"Nicholas Galilei"},"116":{"title":"City running out of parking space","author":"Dan Tannerson"},"119":{"title":"My family lives on a space station","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"},"127":{"title":"Ice-cream parlor closes because of spider infestation","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"119":{"title":"My family lives on a space station","author":"Farlow Cruz"},"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"117":{"title":"Life in the city is still fun","author":"Greg Jenner"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"112":{"title":"The Ocean is Sinking","author":"Kat Stropher"},"114":{"title":"Family friendly fun at the ocean exhibit","author":"Guy Prosales"},"117":{"title":"Life in the city is still fun","author":"Greg Jenner"},"118":{"title":"Sinking prices for real-estate in the city","author":"Paco Rodriguez"},"122":{"title":"The family that moved to a cave","author":"Ruth Starling"},"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"},"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"},"129":{"title":"Master the art of fancy parking","author":"Dan Tannerson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"122":{"title":"The family that moved to a cave","author":"Ruth Starling"},"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"},"133":{"title":"Dead soldier comes back to life","author":"Moira Gluhm"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"120":{"title":"Miniature Grand Central Station Made of Toothpicks","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"128":{"title":"The soldier of fortune turned master chef","author":"Isaac Sharpe"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"126":{"title":"Monument discovered under ice-cream shop","author":"William Diggs"},"131":{"title":"Cave paintings discovered under grafitti","author":"Farlow Cruz"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"123":{"title":"New spider species discovered in Utah cave","author":"Jed Farmer"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"124":{"title":"Top ten ways to have fun in an oil-drum","author":"Billy Joe Jackson"}}
|
|
@ -0,0 +1 @@
|
||||||
|
{"125":{"title":"Wheel of Fortune cancelled for the fifth time","author":"Millie Mahler"}}
|
|
@ -0,0 +1,202 @@
|
||||||
|
.container {
|
||||||
|
font-family: sans-serif;
|
||||||
|
color: #666;
|
||||||
|
background-color: #ccc;
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10px 340px 10px auto 10px;
|
||||||
|
grid-template-rows: 10px 30px 10px 250px 10px 20px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
grid-row: 2 / 3;
|
||||||
|
position: relative;
|
||||||
|
line-height: 30px;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories {
|
||||||
|
grid-column: 2 / 3;
|
||||||
|
grid-row: 4 / 5;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.autoupdate {
|
||||||
|
grid-column: 2/3;
|
||||||
|
grid-row: 6/7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
grid-column: 4 / 5;
|
||||||
|
grid-row: 2 / 7;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter .filter-word {
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
.filter input[type="text"] {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
top: 0;
|
||||||
|
left: 50px;
|
||||||
|
height: 30px;
|
||||||
|
width: 245px;
|
||||||
|
padding: 0;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-top: 2px;
|
||||||
|
font-size: 16px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
font-size: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ddd;
|
||||||
|
border: 1px gray solid;
|
||||||
|
box-shadow: 0px 0px 4px #888;
|
||||||
|
}
|
||||||
|
.filter button:hover {
|
||||||
|
background: lemonchiffon;
|
||||||
|
}
|
||||||
|
.filter button:active {
|
||||||
|
color: #666;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
list-style-type: none;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: inset 0px 0px 5px #333;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories li {
|
||||||
|
border: solid 1px #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left-width: 7px;
|
||||||
|
box-shadow: 0px 0px 5px #ccc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories li.reading {
|
||||||
|
background-color: lemonchiffon;
|
||||||
|
border-left-color: orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories li.unread {
|
||||||
|
border-left-color: cornflowerblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stories li:hover {
|
||||||
|
background-color: lemonchiffon;
|
||||||
|
}
|
||||||
|
.stories p.title {
|
||||||
|
margin: 10px 10px 0px 10px;
|
||||||
|
}
|
||||||
|
.stories em {
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
.stories p.author {
|
||||||
|
margin: 0px 10px 10px 20px;
|
||||||
|
font-style: italic;
|
||||||
|
font-family: serif;
|
||||||
|
}
|
||||||
|
.stories .loadscreen {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
position: absolute;
|
||||||
|
top: 40%;
|
||||||
|
margin-left: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner:after {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
margin: 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 5px solid #fff;
|
||||||
|
border-color: #ccc transparent #ccc transparent;
|
||||||
|
animation: spinner 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.autoupdate {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
background: #fff;
|
||||||
|
padding: 50px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
box-shadow: inset 0px 0px 5px #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story h1 {
|
||||||
|
background-color: limegreen;
|
||||||
|
color: #fff;
|
||||||
|
margin: -40px -40px 40px -40px;
|
||||||
|
padding: 50px 50px 20px 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story p {
|
||||||
|
text-align: justify;
|
||||||
|
font-family: serif;
|
||||||
|
line-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story p.signature {
|
||||||
|
text-align: right;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
|
@ -0,0 +1,591 @@
|
||||||
|
<p>Welcome! If you're new to Hyperapp, you've found the perfect place to start learning.</p><p>Table of contents</p><ul>
|
||||||
|
<li><a href="#setup">The Set-up</a></li>
|
||||||
|
<li><a href="#helloworld">Hello World</a></li>
|
||||||
|
<li><a href="#view">View</a><ul>
|
||||||
|
<li><a href="#virtualnodes">Virtual Nodes</a></li>
|
||||||
|
<li><a href="#rendertodom">Rendering to the DOM</a></li>
|
||||||
|
<li><a href="#composingview">Composing the view with reusable functions</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#state">State</a></li>
|
||||||
|
<li><a href="#actions">Actions</a><ul>
|
||||||
|
<li><a href="#reacting">Reacting to events in the DOM</a></li>
|
||||||
|
<li><a href="#eventdata">Capturing event-data in actions</a></li>
|
||||||
|
<li><a href="#custompayloads">Actions with custom payloads</a></li>
|
||||||
|
<li><a href="#payloadfilters">Payload filters</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#effects">Effects</a><ul>
|
||||||
|
<li><a href="#declaringeffects">Declaring effects in actions</a></li>
|
||||||
|
<li><a href="#effectfunctions">Effect functions and <code>dispatch</code></a></li>
|
||||||
|
<li><a href="#effectsoninit">Running effects on initialization</a></li>
|
||||||
|
<li><a href="#effectcreators">Effect creators</a></li>
|
||||||
|
<li><a href="#trackingasync">Tracking state for ansynchronous effects</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#subscriptions">Subscriptions</a><ul>
|
||||||
|
<li><a href="#subscriptionfunctions">Subscription functions</a></li>
|
||||||
|
<li><a href="#subscribing">Subscribing</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><a href="#conclusion">Conclusion</a></li>
|
||||||
|
</ul><h2 id="the-set-up-a-namesetupa">The Set-up <a name="setup"></a></h2><p>Together we'll build a simple newsreader-like application. As we do, we'll work
|
||||||
|
our way through the five core concepts: view, state, actions, effects and subscriptions.</p><p>To move things along, let's imagine we've already made a static version of the
|
||||||
|
app we want to build, with this HTML:</p><pre><code class="language-html"><div id="app" class="container">
|
||||||
|
<div class="filter">
|
||||||
|
Filter:
|
||||||
|
<span class="filter-word">ocean</span>
|
||||||
|
<button>&#9998;</button>
|
||||||
|
</div>
|
||||||
|
<div class="stories">
|
||||||
|
<ul>
|
||||||
|
<li class="unread">
|
||||||
|
<p class="title">The <em>Ocean </em>is Sinking</p>
|
||||||
|
<p class="author">Kat Stropher</p>
|
||||||
|
</li>
|
||||||
|
<li class="reading">
|
||||||
|
<p class="title"><em>Ocean </em>life is brutal</p>
|
||||||
|
<p class="author">Surphy McBrah</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p class="title">
|
||||||
|
Family friendly fun at the
|
||||||
|
<em>ocean </em>exhibit
|
||||||
|
</p>
|
||||||
|
<p class="author">Guy Prosales</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="story">
|
||||||
|
<h1>Ocean life is brutal</h1>
|
||||||
|
<p>
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||||
|
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
||||||
|
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
||||||
|
commodo consequat.
|
||||||
|
</p>
|
||||||
|
<p class="signature">Surphy McBrah</p>
|
||||||
|
</div>
|
||||||
|
<div class="autoupdate">
|
||||||
|
Auto update:
|
||||||
|
<input type="checkbox" />
|
||||||
|
</div>
|
||||||
|
</div></code></pre><p>...and some CSS <a href="https://hyperapp.dev/tutorial-assets/style.css">here</a>.</p><p>It looks like this:</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut1.png" alt="what it looks like"></p><p>We'll start by making Hyperapp render the HTML for us. Then we will
|
||||||
|
add dynamic behavior to all the widgets, including text input and
|
||||||
|
dynamically fetching stories.</p><p>First, let's begin with the traditional "Hello World!"</p><h2 id="hello-world-a-namehelloworlda">Hello World <a name="helloworld"></a></h2><p>Create this html file:</p><pre><code class="language-html"><!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://hyperapp.dev/tutorial-assets/style.css"
|
||||||
|
/>
|
||||||
|
<script type="module">
|
||||||
|
import { h, app } from "https://unpkg.com/hyperapp"
|
||||||
|
|
||||||
|
// -- EFFECTS & SUBSCRIPTIONS --
|
||||||
|
|
||||||
|
// -- ACTIONS --
|
||||||
|
|
||||||
|
// -- VIEWS ---
|
||||||
|
|
||||||
|
// -- RUN --
|
||||||
|
app({
|
||||||
|
node: document.getElementById("app"),
|
||||||
|
view: () => h("h1", {}, ["Hello ", h("i", {}, "World!")]),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html></code></pre><blockquote>
|
||||||
|
<p>The section structure outlined in the comments is not important. It's
|
||||||
|
just a suggestion for how to organize the code we'll be
|
||||||
|
adding throughout the tutorial.</p>
|
||||||
|
</blockquote><p>Open it in a browser, and you'll be greeted with an optimistic <strong>Hello <em>World!</em></strong>.</p><h2 id="view-a-nameviewa">View <a name="view"></a></h2><p>Let's step through what just happened.</p><h3 id="virtual-nodes-a-namevirtualnodesa">Virtual Nodes <a name="virtualnodes"></a></h3><p>Hyperapp exports the <code>app</code> and <code>h</code> functions.
|
||||||
|
<code>h</code> is for creating <em>virtual nodes</em>, which is to say: plain javascript objects
|
||||||
|
which <em>represent</em> DOM nodes.</p><p>The result of</p><pre><code class="language-js">h("h1", {}, ["Hello ", h("i", {}, "World!")])</code></pre><p>is a virtual node, representing</p><pre><code class="language-html"><h1>
|
||||||
|
Hello
|
||||||
|
<i>World!</i>
|
||||||
|
</h1></code></pre><h3 id="rendering-to-the-dom-a-namerendertodoma">Rendering to the DOM <a name="rendertodom"></a></h3><p><code>app</code> is the function that runs our app. It is called with a single argument - an object
|
||||||
|
which can take several properties. For now we're just concerned with <code>view</code> and <code>node.</code></p><p>Hyperapp calls the <code>view</code> function which tells it the DOM structure we want, in the form
|
||||||
|
of virtual nodes. Hyperapp proceeds to create it for us, replacing the node specified in <code>node</code>.</p><p>To render the HTML we want, change the <code>view</code> to:</p><pre><code class="language-js">view: () => h("div", {id: "app", class: "container"}, [
|
||||||
|
h("div", {class: "filter"}, [
|
||||||
|
" Filter: ",
|
||||||
|
h("span", {class: "filter-word"}, "ocean"),
|
||||||
|
h("button", {}, "\u270E")
|
||||||
|
]),
|
||||||
|
h("div", {class: "stories"}, [
|
||||||
|
h("ul", {}, [
|
||||||
|
h("li", {class: "unread"}, [
|
||||||
|
h("p", {class: "title"}, [
|
||||||
|
"The ",
|
||||||
|
h("em", {}, "Ocean"),
|
||||||
|
" is Sinking!"
|
||||||
|
]),
|
||||||
|
h("p", {class: "author"}, "Kat Stropher")
|
||||||
|
]),
|
||||||
|
h("li", {class: "reading"}, [
|
||||||
|
h("p", {class: "title"}, [
|
||||||
|
h("em", {}, "Ocean"),
|
||||||
|
" life is brutal"
|
||||||
|
]),
|
||||||
|
h("p", {class: "author"}, "Surphy McBrah"),
|
||||||
|
]),
|
||||||
|
h("li", {}, [
|
||||||
|
h("p", {class: "title"}, [
|
||||||
|
"Family friendly fun at the ",
|
||||||
|
h("em", {}, "ocean"),
|
||||||
|
" exhibit"
|
||||||
|
]),
|
||||||
|
h("p", {class: "author"}, "Guy Prosales")
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
h("div", {class: "story"}, [
|
||||||
|
h("h1", {}, "Ocean life is brutal"),
|
||||||
|
h("p", {}, `
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing
|
||||||
|
elit, sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua. Ut enim ad minim veniam, quis
|
||||||
|
nostrud exercitation ullamco laboris nisi ut aliquip
|
||||||
|
ex ea commodo consequat.
|
||||||
|
`),
|
||||||
|
h("p", {class: "signature"}, "Surphy McBrah")
|
||||||
|
]),
|
||||||
|
h("div", {class: "autoupdate"}, [
|
||||||
|
"Auto update: ",
|
||||||
|
h("input", {type: "checkbox"})
|
||||||
|
])
|
||||||
|
]),</code></pre><p>Try it out to confirm that the result matches the screenshot above.</p><blockquote>
|
||||||
|
<p>In many frameworks it is common to write your views/templates
|
||||||
|
using syntax that looks like HTML. This is possible with Hyperapp as well.
|
||||||
|
<a href="https://babeljs.io/docs/en/babel-plugin-transform-react-jsx">JSX</a>
|
||||||
|
can compile a HTML-like syntax into <code>h</code> calls at build-time. If you'd rather
|
||||||
|
not use a build system, <a href="https://github.com/developit/htm">htm</a> does the same at run-time.</p>
|
||||||
|
<p>In this tutorial we'll stick with <code>h</code> to keep it simple and close to the metal.</p>
|
||||||
|
</blockquote><h3 id="composing-the-view-with-reusable-functions-a-namecomposingviewa">Composing the view with reusable functions <a name="composingview"></a></h3><p>The great thing about using plain functions to build up our virtual DOM
|
||||||
|
is that we can break out repetitive or complicated parts into their own functions.</p><p>Add this function (in the "VIEWS" section):</p><pre><code class="language-js">const emphasize = (word, string) =>
|
||||||
|
string.split(" ").map(x => {
|
||||||
|
if (x.toLowerCase() === word.toLowerCase()) {
|
||||||
|
return h("em", {}, x + " ")
|
||||||
|
} else {
|
||||||
|
return x + " "
|
||||||
|
}
|
||||||
|
})</code></pre><p>It lets you change this:</p><pre><code class="language-js"> ...
|
||||||
|
h("p", {class: "title"}, [
|
||||||
|
"The ",
|
||||||
|
h("em", {}, "Ocean"),
|
||||||
|
" is Sinking!"
|
||||||
|
]),
|
||||||
|
...</code></pre><p>into this:</p><pre><code class="language-js"> ...
|
||||||
|
h("p", {class: "title"}, emphasize("ocean",
|
||||||
|
"The Ocean is Sinking"
|
||||||
|
))
|
||||||
|
...</code></pre><p>Story thumbnails are repeated several times, so encapsulate
|
||||||
|
them in their own function:</p><pre><code class="language-js">const storyThumbnail = props =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{
|
||||||
|
class: {
|
||||||
|
unread: props.unread,
|
||||||
|
reading: props.reading,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("p", { class: "title" }, emphasize(props.filter, props.title)),
|
||||||
|
h("p", { class: "author" }, props.author),
|
||||||
|
]
|
||||||
|
)</code></pre><blockquote>
|
||||||
|
<p>The last example demonstrates a helpful feature of the <code>class</code> property. When
|
||||||
|
you set it to an object rather than a string, each key with a truthy value
|
||||||
|
will become a class in the class list.</p>
|
||||||
|
</blockquote><p>Continue by creating functions for each section of the view:</p><pre><code class="language-js">const storyList = props =>
|
||||||
|
h("div", { class: "stories" }, [
|
||||||
|
h(
|
||||||
|
"ul",
|
||||||
|
{},
|
||||||
|
Object.keys(props.stories).map(id =>
|
||||||
|
storyThumbnail({
|
||||||
|
id,
|
||||||
|
title: props.stories[id].title,
|
||||||
|
author: props.stories[id].author,
|
||||||
|
unread: !props.stories[id].seen,
|
||||||
|
reading: props.reading === id,
|
||||||
|
filter: props.filter,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const filterView = props =>
|
||||||
|
h("div", { class: "filter" }, [
|
||||||
|
"Filter:",
|
||||||
|
h("span", { class: "filter-word" }, props.filter),
|
||||||
|
h("button", {}, "\u270E"),
|
||||||
|
])
|
||||||
|
|
||||||
|
const storyDetail = props =>
|
||||||
|
h("div", { class: "story" }, [
|
||||||
|
props && h("h1", {}, props.title),
|
||||||
|
props &&
|
||||||
|
h(
|
||||||
|
"p",
|
||||||
|
{},
|
||||||
|
`
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing
|
||||||
|
elit, sed do eiusmod tempor incididunt ut labore et
|
||||||
|
dolore magna aliqua. Ut enim ad minim veniam, qui
|
||||||
|
nostrud exercitation ullamco laboris nisi ut aliquip
|
||||||
|
ex ea commodo consequat.
|
||||||
|
`
|
||||||
|
),
|
||||||
|
props && h("p", { class: "signature" }, props.author),
|
||||||
|
])
|
||||||
|
|
||||||
|
const autoUpdateView = props =>
|
||||||
|
h("div", { class: "autoupdate" }, [
|
||||||
|
"Auto update: ",
|
||||||
|
h("input", { type: "checkbox" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const container = content => h("div", { class: "container" }, content)</code></pre><p>With those the view can be written as:</p><pre><code class="language-js">view: () =>
|
||||||
|
container([
|
||||||
|
filterView({
|
||||||
|
filter: "ocean",
|
||||||
|
}),
|
||||||
|
storyList({
|
||||||
|
stories: {
|
||||||
|
"112": {
|
||||||
|
title: "The Ocean is Sinking",
|
||||||
|
author: "Kat Stropher",
|
||||||
|
seen: false,
|
||||||
|
},
|
||||||
|
"113": {
|
||||||
|
title: "Ocean life is brutal",
|
||||||
|
author: "Surphy McBrah",
|
||||||
|
seen: true,
|
||||||
|
},
|
||||||
|
"114": {
|
||||||
|
title: "Family friendly fun at the ocean exhibit",
|
||||||
|
author: "Guy Prosales",
|
||||||
|
seen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reading: "113",
|
||||||
|
filter: "ocean",
|
||||||
|
}),
|
||||||
|
storyDetail({
|
||||||
|
title: "Ocean life is brutal",
|
||||||
|
author: "Surphy McBrah",
|
||||||
|
}),
|
||||||
|
autoUpdateView(),
|
||||||
|
])</code></pre><p>What you see on the page should be exactly the same as before, because we haven't
|
||||||
|
changed what <code>view</code> returns. Using basic functional composition, we were able to make
|
||||||
|
the code a bit more manageable, and that's the only difference.</p><h2 id="state-a-namestatea">State <a name="state"></a></h2><p>With all that view logic broken out in separate functions, <code>view</code> is starting to look like
|
||||||
|
plain <em>data</em>. The next step is to fully separate data from the view.</p><p>Add an <code>init</code> property to your app, with this pure data:</p><pre><code class="language-js"> init: {
|
||||||
|
filter: "ocean",
|
||||||
|
reading: "113",
|
||||||
|
stories: {
|
||||||
|
"112": {
|
||||||
|
title: "The Ocean is Sinking",
|
||||||
|
author: "Kat Stropher",
|
||||||
|
seen: false,
|
||||||
|
},
|
||||||
|
"113": {
|
||||||
|
title: "Ocean life is brutal",
|
||||||
|
author: "Surphy McBrah",
|
||||||
|
seen: true,
|
||||||
|
},
|
||||||
|
"114": {
|
||||||
|
title: "Family friendly fun at the ocean exhibit",
|
||||||
|
author: "Guy Prosales",
|
||||||
|
seen: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},</code></pre><p>The value of <code>init</code> becomes the app's <em>state</em>. Hyperapp calls <code>view</code> with the state
|
||||||
|
as an argument, so it can be reduced to:</p><pre><code class="language-js"> view: state => container([
|
||||||
|
filterView(state),
|
||||||
|
storyList(state),
|
||||||
|
storyDetail(state.reading && state.stories[state.reading]),
|
||||||
|
autoUpdateView(state),
|
||||||
|
]),</code></pre><p>Visually, everything is <em>still</em> the same. If you'd like to see a working example of the code so far, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-1-gq662">here</a></p><h2 id="actions-a-nameactionsa">Actions <a name="actions"></a></h2><p>Now that we know all about rendering views, it's finally time for some <em>action</em>!</p><h3 id="reacting-to-events-in-the-dom-a-namereactinga">Reacting to events in the DOM <a name="reacting"></a></h3><p>The first bit of dynamic behavior we will add is so that when you click
|
||||||
|
the pencil-button, a text input with the filter word appears.</p><p>Add an <code>onclick</code> property to the button in <code>filterView</code>:</p><pre><code class="language-js">const filterView = props =>
|
||||||
|
h("div", { class: "filter" }, [
|
||||||
|
"Filter:",
|
||||||
|
h("span", { class: "filter-word" }, props.filter),
|
||||||
|
h("button", { onclick: StartEditingFilter }, "\u270E"), // <---
|
||||||
|
])</code></pre><p>This makes Hyperapp bind a click-event handler on the button element, so
|
||||||
|
that when the button is clicked, an action named <code>StartEditingFilter</code> is
|
||||||
|
<em>dispatched</em>. Create the action in the "ACTIONS" section:</p><pre><code class="language-js">const StartEditingFilter = state => ({ ...state, editingFilter: true })</code></pre><p>Actions are just functions describing transformations of the state.
|
||||||
|
This action keeps everything in the state the same except for <code>editingFilter</code>
|
||||||
|
which it sets to <code>true</code>.</p><p>When Hyperapp dispatches an action, it replaces the old state with the new
|
||||||
|
one calculated using the action. Then the DOM is modified to match what the
|
||||||
|
view returns for this new state.</p><p>When <code>editingFilter</code> is true, we want to have a text input instead of a
|
||||||
|
span with the filter word. We can express this in <code>filterView</code> using a
|
||||||
|
ternary operator (<code>a ? b : c</code>).</p><pre><code class="language-js">const filterView = props =>
|
||||||
|
h("div", { class: "filter" }, [
|
||||||
|
"Filter:",
|
||||||
|
|
||||||
|
props.editingFilter // <---
|
||||||
|
? h("input", { type: "text", value: props.filter }) // <---
|
||||||
|
: h("span", { class: "filter-word" }, props.filter),
|
||||||
|
|
||||||
|
h("button", { onclick: StartEditingFilter }, "\u270E"),
|
||||||
|
])</code></pre><p>Now, when you click the pencil button the text input appears. But we still need to add
|
||||||
|
a way to go back. We need an action to <code>StopEditingFilter</code>, and a button to dispatch it.</p><p>Add the action:</p><pre><code class="language-js">const StopEditingFilter = state => ({ ...state, editingFilter: false })</code></pre><p>and update <code>filterView</code> again:</p><pre><code class="language-js">const filterView = props =>
|
||||||
|
h("div", { class: "filter" }, [
|
||||||
|
"Filter:",
|
||||||
|
|
||||||
|
props.editingFilter
|
||||||
|
? h("input", { type: "text", value: props.filter })
|
||||||
|
: h("span", { class: "filter-word" }, props.filter),
|
||||||
|
|
||||||
|
props.editingFilter // <---
|
||||||
|
? h("button", { onclick: StopEditingFilter }, "\u2713")
|
||||||
|
: h("button", { onclick: StartEditingFilter }, "\u270E"), // <---
|
||||||
|
])</code></pre><p>When you click the pencil button, it is replaced with a check-mark button that can take you back to the first state.</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut2.png" alt="editing filter word"></p><h3 id="capturing-event-data-in-actions-a-nameeventdataa">Capturing event-data in actions <a name="eventdata"></a></h3><p>The next step is to use the input for editing the filter word. Whatever we
|
||||||
|
type in the box should be emphasized in the story-list.</p><p>Update <code>filterView</code> yet again:</p><pre><code class="language-js">const filterView = props =>
|
||||||
|
h("div", { class: "filter" }, [
|
||||||
|
"Filter:",
|
||||||
|
|
||||||
|
props.editingFilter
|
||||||
|
? h("input", {
|
||||||
|
type: "text",
|
||||||
|
value: props.filter,
|
||||||
|
oninput: SetFilter, // <----
|
||||||
|
})
|
||||||
|
: h("span", { class: "filter-word" }, props.filter),
|
||||||
|
|
||||||
|
props.editingFilter
|
||||||
|
? h("button", { onclick: StopEditingFilter }, "\u2713")
|
||||||
|
: h("button", { onclick: StartEditingFilter }, "\u270E"),
|
||||||
|
])</code></pre><p>This will dispatch the <code>SetFilter</code> action everytime someone types in the input. Implement the action like this:</p><pre><code class="language-js">const SetFilter = (state, event) => ({ ...state, filter: event.target.value })</code></pre><p>The second argument to an action is known as the <em>payload</em>. Actions
|
||||||
|
dispatched in response to an events on DOM elements receive the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Event">event object</a> for a payload. <code>event.target</code> refers to the input element in the DOM, and
|
||||||
|
<code>event.target.value</code> refers to the current value entered into it.</p><p>Now see what happens when you erase "ocean" and type "friendly" instead:</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut3.png" alt="typed friendly in filter"></p><h3 id="actions-with-custom-payloads-a-namecustompayloadsa">Actions with custom payloads <a name="custompayloads"></a></h3><p>Next up: selecting stories by clicking them in the list.</p><p>The following action sets the <code>reading</code> property in the state to a story-id, which amounts to "selecting" the story:</p><pre><code class="language-js">const SelectStory = (state, id) => ({ ...state, reading: id })</code></pre><p>It has a payload, but it's not an event object. It's a custom value telling us which
|
||||||
|
story was clicked. How are actions dispatched with custom payloads? – Like this:</p><pre><code class="language-js">const storyThumbnail = props =>
|
||||||
|
h(
|
||||||
|
"li",
|
||||||
|
{
|
||||||
|
onclick: [SelectStory, props.id], // <----
|
||||||
|
class: {
|
||||||
|
unread: props.unread,
|
||||||
|
reading: props.reading,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[
|
||||||
|
h("p", { class: "title" }, emphasize(props.filter, props.title)),
|
||||||
|
h("p", { class: "author" }, props.author),
|
||||||
|
]
|
||||||
|
)</code></pre><p>Instead of just specifying the action, we give a length-2 array with the action first and the custom payload second.</p><p>Selecting stories works now, but the feature is not quite done. When a story is selected,
|
||||||
|
we need to set its <code>seen</code> property to <code>true</code>, so we can highlight which stories the user has yet to read. Update the <code>SelectStory</code> action:</p><pre><code class="language-js">const SelectStory = (state, id) => ({
|
||||||
|
...state, // keep all state the same, except for the following:
|
||||||
|
reading: id,
|
||||||
|
stories: {
|
||||||
|
...state.stories, //keep stories the same, except for:
|
||||||
|
[id]: {
|
||||||
|
...state.stories[id], //keep this story the same, except for:
|
||||||
|
seen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})</code></pre><p>Now, when you select a blue-edged story it turns yellow because it is selected, and when you select something else,
|
||||||
|
the edge turns gray to indicate you've read the story.</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut4.png" alt="read stories are gray"></p><h3 id="payload-filters-a-namepayloadfiltersa">Payload filters <a name="payloadfilters"></a></h3><p>There's one little thing we should fix about <code>SetFilter</code>. See how it's dependent on the complex <code>event</code> object?
|
||||||
|
It would be easier to test and reuse if it were simply:</p><pre><code class="language-js">const SetFilter = (state, word) => ({ ...state, filter: word })</code></pre><p>But we don't know the word beforehand, so how can we set it as a custom payload? Change the <code>Filter</code> view
|
||||||
|
again (last time - I promise!):</p><pre><code class="language-js">const filterView = props =>
|
||||||
|
h("div", { class: "filter" }, [
|
||||||
|
"Filter:",
|
||||||
|
|
||||||
|
props.editingFilter
|
||||||
|
? h("input", {
|
||||||
|
type: "text",
|
||||||
|
value: props.filter,
|
||||||
|
oninput: [SetFilter, event => event.target.value], // <----
|
||||||
|
})
|
||||||
|
: h("span", { class: "filter-word" }, props.filter),
|
||||||
|
|
||||||
|
props.editingFilter
|
||||||
|
? h("button", { onclick: StopEditingFilter }, "\u2713")
|
||||||
|
: h("button", { onclick: StartEditingFilter }, "\u270E"),
|
||||||
|
])</code></pre><p>When we give a <em>function</em> as the custom payload, Hyperapp considers it a <em>payload filter</em> and passes the default
|
||||||
|
payload through it, providing the returned value as payload to the action.</p><blockquote>
|
||||||
|
<p>Payload filters are also useful when you need a payload that is a combination of custom data and event data</p>
|
||||||
|
</blockquote><p>If you'd like to see a working example of the code so far, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-2-5yv34">here</a></p><h2 id="effects-a-nameeffectsa">Effects <a name="effects"></a></h2><p>So far, the list of stories has been defined in the state and doesn't change. What we really want is
|
||||||
|
when we're done changing the filter-word, stories matching it should be loaded.</p><p>Before looking at how we make the request for new stories, one thing is for sure: when new stories
|
||||||
|
come back they need to go into the state, and the only way to modify the state is through an action.
|
||||||
|
So we're definitely going to need the following action:</p><pre><code class="language-js">const GotStories = (state, stories) => ({
|
||||||
|
...state,
|
||||||
|
|
||||||
|
// replace old stories with new,
|
||||||
|
// but keep the 'seen' value if it exists
|
||||||
|
stories: Object.keys(stories)
|
||||||
|
.map(id => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
...stories[id],
|
||||||
|
seen: state.stories[id] && state.stories[id].seen,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.reduce((o, [id, story]) => ((o[id] = story), o), {}),
|
||||||
|
|
||||||
|
// in case the current story is in the new list as well,
|
||||||
|
// keep it selected, Otherwise select nothing
|
||||||
|
reading: stories[state.reading] ? state.reading : null,
|
||||||
|
})</code></pre><h3 id="declaring-effects-in-actions-a-namedeclaringeffectsa">Declaring effects in actions <a name="declaringeffects"></a></h3><p>Our request for new stories should go out once we're done editing the filter, which is to say: when we click
|
||||||
|
the check-mark button and <code>StopEditingFilter</code> is dispatched. When an action needs to do something
|
||||||
|
besides transforming the state, that "something" is called an <em>effect</em>. To associate an effect
|
||||||
|
with <code>StopEditingFilter</code>, make it return an array like this:</p><pre><code class="language-js">const StopEditingFilter = state => [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
editingFilter: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// effect declarations go here: //
|
||||||
|
]</code></pre><p>When an action returns an array, Hyperapp understands that the first item is the new state we want, and
|
||||||
|
the rest are <em>effect declarations</em>. Hyperapp takes care of running all declared effects once the state
|
||||||
|
has been updated.</p><p>Add this effect declaration:</p><pre><code class="language-js">const StopEditingFilter = state => [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
editingFilter: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// effect declarations go here: //
|
||||||
|
[
|
||||||
|
fetchJSONData,
|
||||||
|
{
|
||||||
|
url: `https://hyperapp.dev/tutorial-assets/stories/${state.filter.toLowerCase()}.json`,
|
||||||
|
onresponse: GotStories,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]</code></pre><p>The first item in an effect declaration – here <code>fetchJSONData</code> – is the
|
||||||
|
<em>effect function</em> that we want Hyperapp to call. The second item contains
|
||||||
|
the options we want passed to effect function when it's called. Here, we are
|
||||||
|
telling <code>fetchJSONData</code> where the stories for the current filter are, and
|
||||||
|
to dispatch them as payload to <code>GotStories</code>, on response.</p><h3 id="effect-functions-and-dispatch-a-nameeffectfunctionsa">Effect functions and <code>dispatch</code> <a name="effectfunctions"></a></h3><p>Now we just need to implement <code>fetchJSONData</code>. Type this in the "EFFECTS & SUBSCRIPTIONS" section:</p><pre><code class="language-js">const fetchJSONData = (dispatch, options) =>
|
||||||
|
fetch(options.url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => dispatch(options.onresponse, data))
|
||||||
|
.catch(() => dispatch(options.onresponse, {}))</code></pre><blockquote>
|
||||||
|
<p>It's a good practice to write your effect functions generically like this, rather than
|
||||||
|
hardcoding options. That way it can be used for multiple situations, even by others
|
||||||
|
if you chose to publish it.</p>
|
||||||
|
<p>...speaking of which: make sure to check out the available effects published by members of the
|
||||||
|
Hyperapp community, and perhaps save yourself some trouble implementing everything yourself.</p>
|
||||||
|
</blockquote><p>When Hyperapp calls an effect function, it passes the <code>dispatch</code> function to it as the first
|
||||||
|
argument. <code>dispatch</code> is how effect functions are able to "report back" to the app, by dispatching
|
||||||
|
actions (first argument) with payloads (second argument)</p><p>Now, go ahead and try it out! Enter "life" in the filter input. When you click the check-mark button some new
|
||||||
|
stories are loaded – all with blue edges except for "Ocean life is brutal" because it is
|
||||||
|
still selected.</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut5.png" alt="fetched life stories"></p><h3 id="running-effects-on-initialization-a-nameeffectsoninita">Running effects on initialization <a name="effectsoninit"></a></h3><p>The next obvious step is to load the <em>initial</em> stories from the API as well. Change init to this:</p><pre><code class="language-js"> init: [
|
||||||
|
{
|
||||||
|
editingFilter: false,
|
||||||
|
autoUpdate: false,
|
||||||
|
filter: "ocean",
|
||||||
|
reading: null,
|
||||||
|
stories: {}, // <---
|
||||||
|
},
|
||||||
|
[ // <---
|
||||||
|
fetchJSONData, // <---
|
||||||
|
{ // <---
|
||||||
|
url: `https://hyperapp.dev/tutorial-assets/stories/ocean.json`, // <---
|
||||||
|
onresponse: GotStories // <---
|
||||||
|
} // <---
|
||||||
|
] // <---
|
||||||
|
],</code></pre><p>The point here is that init works just like the return value of an action, including
|
||||||
|
calling effects when it is given as an array. If you reload the page you'll see
|
||||||
|
(after a moment) that all the same stories appear, despite them not existing in
|
||||||
|
the state initially.</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut6.png" alt="fresh stories on init"></p><h3 id="effect-creators-a-nameeffectcreatorsa">Effect creators <a name="effectcreators"></a></h3><p>However, repeating the effect declaration in all its gory detail like this
|
||||||
|
is not ideal, so lets add this <em>effect creator</em></p><pre><code class="language-js">const storyLoader = searchWord => [
|
||||||
|
fetchJSONData,
|
||||||
|
{
|
||||||
|
url: `https://hyperapp.dev/tutorial-assets/stories/${searchWord.toLowerCase()}.json`,
|
||||||
|
onresponse: GotStories,
|
||||||
|
},
|
||||||
|
]</code></pre><p>Now we can simplify <code>StopEditingFilter</code> like this:</p><pre><code class="language-js">const StopEditingFilter = state => [
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
editingFilter: false,
|
||||||
|
},
|
||||||
|
storyLoader(state.filter),
|
||||||
|
]</code></pre><p>... and <code>init:</code> like this:</p><pre><code class="language-js"> init: [
|
||||||
|
{
|
||||||
|
editingFilter: false,
|
||||||
|
autoUpdate: false,
|
||||||
|
filter: "ocean",
|
||||||
|
reading: null,
|
||||||
|
stories: {},
|
||||||
|
},
|
||||||
|
storyLoader("ocean")
|
||||||
|
],</code></pre><h3 id="tracking-state-for-asynchronous-effects-a-nametrackingasynca">Tracking state for asynchronous effects <a name="trackingasync"></a></h3><p>If we could display a spinner while we wait for stories to load, it would make for a smoother user experience. We'll need a state property to tell us wether or not we're currently <code>fetching</code>, and we'll use this action to keep track of it:</p><pre><code class="language-js">const SetFetching = (state, fetching) => ({ ...state, fetching })</code></pre><p>Update <code>storyLoader</code> to tell <code>fetchJSONData</code> about <code>SetFetching</code></p><pre><code class="language-js">const storyLoader = searchWord => [
|
||||||
|
fetchJSONData,
|
||||||
|
{
|
||||||
|
url: `https://hyperapp.dev/tutorial-assets/stories/${searchWord.toLowerCase()}.json`,
|
||||||
|
onresponse: GotStories,
|
||||||
|
onstart: [SetFetching, true], // <----
|
||||||
|
onfinish: [SetFetching, false], // <----
|
||||||
|
},
|
||||||
|
]</code></pre><p>Finally update <code>fetchJSONData</code> to use the new <code>onstart</code> and <code>onfinish</code> options to notify when fetches start and end:</p><pre><code class="language-js">const fetchJSONData = (dispatch, options) => {
|
||||||
|
dispatch(options.onstart) // <---
|
||||||
|
fetch(options.url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => dispatch(options.onresponse, data))
|
||||||
|
.catch(() => dispatch(options.onresponse, {}))
|
||||||
|
.finally(() => dispatch(options.onfinish)) // <---
|
||||||
|
}</code></pre><p>With that, our state prop <code>fetching</code> will always tell us wether or not we are fetching.
|
||||||
|
Use that to show a spinner when we are fetching, in <code>storyList</code>:</p><pre><code class="language-js">const storyList = props =>
|
||||||
|
h("div", { class: "stories" }, [
|
||||||
|
// show spinner overlay if fetching
|
||||||
|
props.fetching &&
|
||||||
|
h("div", { class: "loadscreen" }, [h("div", { class: "spinner" })]),
|
||||||
|
|
||||||
|
h(
|
||||||
|
"ul",
|
||||||
|
{},
|
||||||
|
Object.keys(props.stories).map(id =>
|
||||||
|
storyThumbnail({
|
||||||
|
id,
|
||||||
|
title: props.stories[id].title,
|
||||||
|
author: props.stories[id].author,
|
||||||
|
unread: !props.stories[id].seen,
|
||||||
|
reading: props.reading === id,
|
||||||
|
filter: props.filter,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])</code></pre><p>When the app loads, and when you change the filter, you should see the spinner appear until the stories are loaded.</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut7.png" alt="spinner"></p><blockquote>
|
||||||
|
<p>If you aren't seeing the spinner, it might just be happening too fast. Try choking your network speed. In the Chrome
|
||||||
|
browser you can set your network speed to "slow 3g" under the network tab in the developer tools.</p>
|
||||||
|
</blockquote><p>If you'd like to see a working example of the code so far, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-3-2mmug">here</a></p><h2 id="subscriptions-a-namesubscriptionsa">Subscriptions <a name="subscriptions"></a></h2><p>The last feature we'll add is one where the user can opt in to have the app check every five seconds for new
|
||||||
|
stories matching the current filter. (There won't actually be any new stories, because it's not a real service,
|
||||||
|
but you'll know it's happening when you see the spinner pop up every five seconds.)</p><p>First let's keep track of wether or not the user wants this auto-update feature on. Create a new action:</p><pre><code class="language-js">const ToggleAutoUpdate = state => ({ ...state, autoUpdate: !state.autoUpdate })</code></pre><p>Dispatch it in response to checking the checkbox in <code>autoUpdateView</code>:</p><pre><code class="language-js">const autoUpdateView = props =>
|
||||||
|
h("div", { class: "autoupdate" }, [
|
||||||
|
"Auto update: ",
|
||||||
|
h("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: props.autoUpdate, // <---
|
||||||
|
oninput: ToggleAutoUpdate, // <---
|
||||||
|
}),
|
||||||
|
])</code></pre><p>With that, the state property <code>autoUpdate</code> will tell us wether or not the Auto-update checkbox is checked.</p><h3 id="subscription-functions-a-namesubscriptionfunctionsa">Subscription functions <a name="subscriptionfunctions"></a></h3><p>We need a <em>subscription function</em> capable of dispatching actions at a given interval. Implement
|
||||||
|
<code>intervalSubscription</code> in the "EFFECTS & SUBSCRIPTIONS" section:</p><pre><code class="language-js">const intervalSubscription = (dispatch, options) => {
|
||||||
|
const interval = setInterval(() => dispatch(options.action), options.time)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}</code></pre><p>Just like an effect function, this function will be called by Hyperapp with <code>dispatch</code> and given options. It
|
||||||
|
will start an interval listener, and every <code>options.time</code> milliseconds, it will dispatch the given action. The
|
||||||
|
main difference to an effect function is that a subscription function returns a function so hyperapp knows
|
||||||
|
how to stop the subscription.</p><blockquote>
|
||||||
|
<p>As with effects, you may find a suitable subscription already published
|
||||||
|
in the Hyperapp community.</p>
|
||||||
|
</blockquote><h3 id="subscribing-a-namesubscribinga">Subscribing <a name="subscribing"></a></h3><p>We could create a new action for updating stories, but since <code>StopEditingFilter</code> already does what we want, we'll
|
||||||
|
use it here too. Add a <code>subscription</code> property to the app:</p><pre><code class="language-js">subscriptions: state => [
|
||||||
|
state.autoUpdate &&
|
||||||
|
!state.editingFilter && [
|
||||||
|
intervalSubscription,
|
||||||
|
{
|
||||||
|
time: 5000, //milliseconds,
|
||||||
|
action: StopEditingFilter,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]</code></pre><p>Just like for <code>view</code>, hyperapp will run <code>subscriptions</code> with the new state every time it changes, to get
|
||||||
|
a list of subscription-declarations that should be active. In our case, whenever the Auto Update checkbox is
|
||||||
|
checked and we are <em>not</em> busy editing the filter, our interval subscription will be active.</p><p><img src="https://raw.githubusercontent.com/jorgebucaran/hyperapp/1fd42319051e686adb9819b7e154f764fa3b0d29/docs/src/pages/Tutorial/tut8.png" alt="auto update"></p><p>Hyperapp will only stop or start subscriptions when the declaration changes
|
||||||
|
from one state to the next. Subscriptions are <em>not</em> stopped and started <em>every</em> time the state changes.</p><p>If you'd like to see a working example of the final code, have a look <a href="https://codesandbox.io/s/hyperapp-tutorial-step-4-8u9q8">here</a></p><h2 id="conclusion-a-nameconclusiona">Conclusion <a name="conclusion"></a></h2><p>Congratulations on completing this Hyperapp tutorial!</p><p>Along the way you've familiarized yourself with
|
||||||
|
the core concepts: <em>view</em>, <em>state</em>, <em>actions</em>, <em>effects</em> & <em>subscriptions</em>. And that's really all you need to
|
||||||
|
build any web application.</p>
|
Loading…
Reference in New Issue