Compare commits
170 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66094700c4 | |||
| d31b9dd661 | |||
| b9e3cb8119 | |||
| 354a707119 | |||
| 3ec84cb051 | |||
| f744cad4f9 | |||
| 46870e98bf | |||
| 176b28ae6b | |||
| 75a0c00828 | |||
| 90db6e81b0 | |||
| 9edc567a13 | |||
| 34d3b7e6ec | |||
| acea5cefa0 | |||
| 34b7e6e52e | |||
| a28ef48931 | |||
| 633a1efbd9 | |||
| 412ddaa55f | |||
| 3e6c780a93 | |||
| a329bd2f40 | |||
| aed9d1aa5c | |||
| da5af433f6 | |||
| 3a41e8ce66 | |||
| 3c966ab011 | |||
| 9884d245e2 | |||
| ad816cc3ff | |||
| 58194312b6 | |||
| 6cb0a2093a | |||
| 48e2838bd4 | |||
| 9c4cb65c18 | |||
| ef7787d670 | |||
| 4d853d99fd | |||
| 39a344cd4c | |||
| 3effe9e9d9 | |||
| 3453035ffd | |||
| 5cb106f8d1 | |||
| c5027f6ce0 | |||
| 8abd06f233 | |||
| b09cc426fd | |||
| 73aa1ed975 | |||
| 80e8cec98f | |||
| 29125fbc8f | |||
| 8574576934 | |||
| 426ee74b86 | |||
| d06b6427e5 | |||
| f674328569 | |||
| 0873b78865 | |||
| d3d71508a7 | |||
| e672b06038 | |||
| 5dcf50c64f | |||
| 927c6977a7 | |||
| c2b9a00446 | |||
| 2978c6e193 | |||
| f1fb34aaa4 | |||
| 13bc2dada2 | |||
| 59ad6c4c0a | |||
| bc62d92609 | |||
| 5226fdd2e8 | |||
| df15488e97 | |||
| 3002567065 | |||
| 5d27ab52a7 | |||
| 7ac13cb8f5 | |||
| 91438546ba | |||
| fd3ceff2d2 | |||
| bd72768e6f | |||
| 50da3881b1 | |||
| 8614a564f7 | |||
| 144dad1229 | |||
| 1688e5ec38 | |||
| 493b5681d4 | |||
| 353623f108 | |||
| cabc9760c3 | |||
| 8dfa9f70b7 | |||
| fc4a70fc28 | |||
| 17f96d1b56 | |||
| fa84d31f62 | |||
| 9b8c76bca7 | |||
| 5777848d74 | |||
| 84b9e97023 | |||
| 7208ff4557 | |||
| 8f67672ffe | |||
| b76a1f17e9 | |||
| 42f4182e1b | |||
| 032194c6be | |||
| e44234821b | |||
| 97fcafe4fa | |||
| f7821637b9 | |||
| 39ce9ac8ee | |||
| facfbccd62 | |||
| 8df12a0e3d | |||
| 82dfd6436e | |||
| 6a0a7ea941 | |||
| c2ce139c69 | |||
| d879bd17cd | |||
| e374fd8b64 | |||
| 42a4127697 | |||
| 2e804be843 | |||
| e8d4ea8c7a | |||
| 1ea49f8bda | |||
| 2e0d00f947 | |||
| a33c199c11 | |||
| 46d8a711d5 | |||
| 2163df4cb3 | |||
| c247c24a23 | |||
| 73e79d5176 | |||
| 6a3879b1c7 | |||
| f6bd524cb6 | |||
| d1325d4dbb | |||
| b023ec4714 | |||
| 35c3b048d3 | |||
| 7b12aa570f | |||
| 31d96cd537 | |||
| d57dec1681 | |||
| 9989159375 | |||
| 074c5505f9 | |||
| ac768e5394 | |||
| 7793c69762 | |||
| bf20622319 | |||
| f89b1aa197 | |||
| edbbcacb7c | |||
| ca4ffa26c3 | |||
| c34810f421 | |||
| 8ae366a175 | |||
| b54355b4df | |||
| 2592391085 | |||
| 6fe177f68b | |||
| fecc3fe291 | |||
| faa5b834c1 | |||
| 522eafdea4 | |||
| badb19b014 | |||
| adbbf890b7 | |||
| ca8a7b8b79 | |||
| 2ee35c67e2 | |||
| c756e9bcb2 | |||
| 79d3acd696 | |||
| 4944d215c3 | |||
| af4ad8e46b | |||
| e6ec9a6584 | |||
| 4ad5a6d7c9 | |||
| fe3780664f | |||
| 496ff2ae1a | |||
| ebd6b4e634 | |||
| 4ecc526f98 | |||
| 825dea437b | |||
| f6bef58e3d | |||
| 3f36c52254 | |||
| b21108bcad | |||
| 9054122539 | |||
| fe62b9304a | |||
| de70b4aa65 | |||
| 70080511e3 | |||
| 2bd0c78583 | |||
| 82e848ba97 | |||
| f17c09108d | |||
| 975a010c1f | |||
| ed22cf2531 | |||
| 0f673557eb | |||
| d22a7a9c47 | |||
| 1871cefee8 | |||
| 783d9301c1 | |||
| a9ff651ebd | |||
| f6d9dd220c | |||
| d7f7cf5329 | |||
| 202fde8eb4 | |||
| 29fbd5c1c1 | |||
| 3b43ef3437 | |||
| 5e232596d9 | |||
| 394b108065 | |||
| b0f8f8ee42 | |||
| d4718c64b4 | |||
| 345368cbcc |
+1
-5
@@ -1,6 +1,2 @@
|
|||||||
node_modules
|
elm-stuff
|
||||||
elm.js
|
elm.js
|
||||||
Auth.elm
|
|
||||||
generated-code
|
|
||||||
build-artifacts
|
|
||||||
*.log
|
|
||||||
|
|||||||
@@ -1,89 +1,21 @@
|
|||||||
https://creativecommons.org/licenses/by/4.0
|
MIT License
|
||||||
|
|
||||||
Creative Commons Attribution 4.0 International Public License
|
Copyright (c) 2017-2018 Richard Feldman and contributors. This license does not apply to the server/ directory - see README for details.
|
||||||
|
|
||||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
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:
|
||||||
|
|
||||||
Section 1 – Definitions.
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
SOFTWARE.
|
||||||
Licensor means the individual(s) or entity(ies) granting rights under this Public License.
|
|
||||||
Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
|
||||||
Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
|
||||||
You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
|
|
||||||
Section 2 – Scope.
|
|
||||||
|
|
||||||
License grant.
|
|
||||||
Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
|
||||||
reproduce and Share the Licensed Material, in whole or in part; and
|
|
||||||
produce, reproduce, and Share Adapted Material.
|
|
||||||
Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
|
||||||
Term. The term of this Public License is specified in Section 6(a).
|
|
||||||
Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
|
|
||||||
Downstream recipients.
|
|
||||||
Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
|
||||||
No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
|
||||||
No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
|
||||||
Other rights.
|
|
||||||
|
|
||||||
Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
|
|
||||||
Patent and trademark rights are not licensed under this Public License.
|
|
||||||
To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
|
|
||||||
Section 3 – License Conditions.
|
|
||||||
|
|
||||||
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
|
|
||||||
|
|
||||||
Attribution.
|
|
||||||
|
|
||||||
If You Share the Licensed Material (including in modified form), You must:
|
|
||||||
|
|
||||||
retain the following if it is supplied by the Licensor with the Licensed Material:
|
|
||||||
identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
|
|
||||||
a copyright notice;
|
|
||||||
a notice that refers to this Public License;
|
|
||||||
a notice that refers to the disclaimer of warranties;
|
|
||||||
a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
|
||||||
indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
|
|
||||||
indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
|
|
||||||
You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
|
|
||||||
If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
|
||||||
If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.
|
|
||||||
Section 4 – Sui Generis Database Rights.
|
|
||||||
|
|
||||||
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
|
||||||
|
|
||||||
for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
|
|
||||||
if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
|
|
||||||
You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
|
||||||
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
|
|
||||||
Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
|
||||||
|
|
||||||
Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.
|
|
||||||
To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.
|
|
||||||
The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
|
|
||||||
Section 6 – Term and Termination.
|
|
||||||
|
|
||||||
This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
|
|
||||||
Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
|
||||||
|
|
||||||
automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
|
||||||
upon express reinstatement by the Licensor.
|
|
||||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
|
||||||
For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
|
||||||
Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
|
||||||
Section 7 – Other Terms and Conditions.
|
|
||||||
|
|
||||||
The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
|
|
||||||
Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
|
|
||||||
Section 8 – Interpretation.
|
|
||||||
|
|
||||||
For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
|
|
||||||
To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
|
|
||||||
No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
|
|
||||||
Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
module Main exposing (..)
|
|
||||||
|
|
||||||
{-| THIS FILE IS NOT PART OF THE WORKSHOP! It is only to verify that you
|
|
||||||
have everything set up properly.
|
|
||||||
-}
|
|
||||||
|
|
||||||
import Auth
|
|
||||||
import Browser exposing (View)
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing (..)
|
|
||||||
import Http
|
|
||||||
import Json.Decode exposing (Decoder)
|
|
||||||
|
|
||||||
|
|
||||||
main : Program () Model Msg
|
|
||||||
main =
|
|
||||||
Browser.fullscreen
|
|
||||||
{ view = view
|
|
||||||
, update = update
|
|
||||||
, init = \env -> ( initialModel, searchFeed )
|
|
||||||
, onNavigation = Nothing
|
|
||||||
, subscriptions = \_ -> Sub.none
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
initialModel : Model
|
|
||||||
initialModel =
|
|
||||||
{ status = "Verifying setup..."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
|
||||||
{ status : String }
|
|
||||||
|
|
||||||
|
|
||||||
searchFeed : Cmd Msg
|
|
||||||
searchFeed =
|
|
||||||
let
|
|
||||||
url =
|
|
||||||
"https://api.github.com/search/repositories?q=test&access_token=" ++ Auth.token
|
|
||||||
in
|
|
||||||
Json.Decode.succeed ()
|
|
||||||
|> Http.get url
|
|
||||||
|> Http.send Response
|
|
||||||
|
|
||||||
|
|
||||||
view : Model -> View Msg
|
|
||||||
view model =
|
|
||||||
{ body =
|
|
||||||
[ div [ class "content" ]
|
|
||||||
[ header [] [ h1 [] [ text "Elm Workshop" ] ]
|
|
||||||
, div
|
|
||||||
[ style "font-size" "48px"
|
|
||||||
, style "text-align" "center"
|
|
||||||
, style "padding" "48px"
|
|
||||||
]
|
|
||||||
[ text model.status ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, title = "Elm Workshop"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= Response (Result Http.Error ())
|
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
||||||
update msg model =
|
|
||||||
case msg of
|
|
||||||
Response (Ok ()) ->
|
|
||||||
( { status = "You're all set!" }, Cmd.none )
|
|
||||||
|
|
||||||
Response (Err err) ->
|
|
||||||
let
|
|
||||||
status =
|
|
||||||
case err of
|
|
||||||
Http.Timeout ->
|
|
||||||
"Timed out trying to contact GitHub. Check your Internet connection?"
|
|
||||||
|
|
||||||
Http.NetworkError ->
|
|
||||||
"Network error. Check your Internet connection?"
|
|
||||||
|
|
||||||
Http.BadUrl url ->
|
|
||||||
"Invalid test URL: " ++ url
|
|
||||||
|
|
||||||
Http.BadPayload error _ ->
|
|
||||||
"Something is misconfigured: " ++ error
|
|
||||||
|
|
||||||
Http.BadStatus response ->
|
|
||||||
case response.status.code of
|
|
||||||
401 ->
|
|
||||||
"Auth.elm does not have a valid token. :( Try recreating Auth.elm by following the steps in the README under the section “Create a GitHub Personal Access Token”."
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
"GitHub's Search API returned an error: "
|
|
||||||
++ String.fromInt response.status.code
|
|
||||||
++ " "
|
|
||||||
++ response.status.message
|
|
||||||
in
|
|
||||||
( { status = status }, Cmd.none )
|
|
||||||
@@ -1,95 +1,37 @@
|
|||||||
<i>This work is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0 International License</a>. Enjoy!</i>
|
<i>This workshop is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0 International License</a>. The `server/` directories use [`moleculer-node-realworld-example`](https://github.com/gothinkster/moleculer-node-realworld-example-app), which has its own license. The JavaScript interop example uses [`localForage`](https://github.com/localForage/localForage), which is (c) 2013-2017 Mozilla, under the Apache License 2.0. The rest of the code is a variation on [`elm-spa-example`](https://github.com/rtfeldman/elm-spa-example/), an [MIT-licensed](https://github.com/rtfeldman/elm-spa-example/blob/master/LICENSE) implementation of the [`realworld`](https://github.com/gothinkster/realworld) front-end. Many thanks to the authors of these projects!</i>
|
||||||
|
|
||||||
Getting Started
|
Getting Started
|
||||||
===============
|
===============
|
||||||
|
|
||||||
## Installation
|
1. Install [Node.js](http://nodejs.org) 7.0.0 or higher
|
||||||
|
|
||||||
1. Install [Node.js](http://nodejs.org) 6.9.2 or higher
|
|
||||||
|
|
||||||
2. Add a plugin for your editor of choice: [Atom](https://atom.io/packages/language-elm), [Sublime Text](https://packagecontrol.io/packages/Elm%20Language%20Support), [VS Code](https://github.com/sbrink/vscode-elm), [Light Table](https://github.com/rundis/elm-light), [Vim](https://github.com/lambdatoast/elm.vim), [Emacs](https://github.com/jcollard/elm-mode), [Brackets](https://github.com/lepinay/elm-brackets)
|
2. Add a plugin for your editor of choice: [Atom](https://atom.io/packages/language-elm), [Sublime Text](https://packagecontrol.io/packages/Elm%20Language%20Support), [VS Code](https://github.com/sbrink/vscode-elm), [Light Table](https://github.com/rundis/elm-light), [Vim](https://github.com/lambdatoast/elm.vim), [Emacs](https://github.com/jcollard/elm-mode), [Brackets](https://github.com/lepinay/elm-brackets)
|
||||||
|
|
||||||
3. Not required, but **highly** recommended: enable "[`elm-format`](https://github.com/avh4/elm-format) on save" in your editor.
|
3. Not required, but **highly** recommended: enable "[`elm-format`](https://github.com/avh4/elm-format) on save" in your editor.
|
||||||
|
|
||||||
4. Run the following command to install everything else:
|
4. Run the following command to install all the other Elm tools:
|
||||||
|
|
||||||
```bash
|
> **Note:** Make sure not to run this command with `sudo`! If it gives you an `EACCESS` error, apply [**this fix**](https://docs.npmjs.com/getting-started/fixing-npm-permissions#option-two-change-npms-default-directory) and then re-run the command (still without `sudo`).
|
||||||
npm install -g elm elm-test elm-css elm-live@2.6.1 elm-format@exp
|
|
||||||
```
|
|
||||||
**Note to macOS users:** If step 4 gives you an `EACCESS` error, try [this fix](https://docs.npmjs.com/getting-started/fixing-npm-permissions):
|
|
||||||
|
|
||||||
|
```shell
|
||||||
```
|
npm install -g elm elm-test@elm0.19.0 elm-format
|
||||||
sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then re-run step 4.
|
5. Clone this repository
|
||||||
|
|
||||||
## Clone this repository
|
|
||||||
|
|
||||||
Run this at the terminal:
|
Run this at the terminal:
|
||||||
|
|
||||||
```bash
|
```shell
|
||||||
git clone https://github.com/rtfeldman/elm-workshop.git
|
git clone https://github.com/rtfeldman/elm-0.19-workshop.git
|
||||||
cd elm-workshop
|
cd elm-0.19-workshop
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** Tab characters are syntax errors in Elm code, so if your editor uses them for indentation, definitely switch it to spaces for this workshop!
|
6. Continue with either the [`intro`](https://github.com/rtfeldman/elm-0.19-workshop/blob/master/intro/README.md) or [`advanced`](https://github.com/rtfeldman/elm-0.19-workshop/blob/master/advanced/README.md) instructions, depending on which workshop you're doing!
|
||||||
|
|
||||||
## Create a GitHub Personal Access Token
|
Video Course of this Workshop
|
||||||
|
=======================
|
||||||
|
|
||||||
We'll be using GitHub's [Search API](https://developer.github.com/v3/search/), and authenticated API access lets us experiment without worrying about the default rate limit. Since we'll only be accessing the Search API, these steps can be done either on your personal GitHub account or on a throwaway account created for this workshop; either way will work just as well.
|
I recorded full-length videos for [Frontend Masters](https://frontendmasters.com/), in which I teach both of these workshops start to finish:
|
||||||
|
|
||||||
1. Visit https://github.com/settings/tokens/new
|
* [Introduction to Elm](https://frontendmasters.com/courses/intro-elm/) video course
|
||||||
2. Enter "Elm Workshop" under "Token description" and leave everything else blank.
|
* [Advanced Elm](https://frontendmasters.com/courses/advanced-elm/) video course
|
||||||
3. Create the token and copy it into a new file called `Auth.elm`:
|
|
||||||
|
|
||||||
#### Auth.elm
|
|
||||||
|
|
||||||
```elm
|
|
||||||
module Auth exposing (token)
|
|
||||||
|
|
||||||
|
|
||||||
token =
|
|
||||||
-- Your token should go here instead of this sample token:
|
|
||||||
"abcdef1234567890abcdef1234567890abcdef12"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Even for a token that has no permissions, good security habits are
|
|
||||||
still important! `Auth.elm` is in `.gitignore` to avoid accidentally checking in
|
|
||||||
an API secret, and you should [delete this token](https://github.com/settings/tokens) when the workshop is over.
|
|
||||||
|
|
||||||
|
|
||||||
## Verify Setup
|
|
||||||
|
|
||||||
Run this to install packages:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
elm-package install --yes
|
|
||||||
```
|
|
||||||
|
|
||||||
Once that succeeds, run this to verify everything:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
elm-live Main.elm --open --output=elm.js
|
|
||||||
```
|
|
||||||
|
|
||||||
A browser should open, and you should see this in it:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
If things aren't working, the instructor will be happy to help!
|
|
||||||
|
|
||||||
## Start with Part 1
|
|
||||||
|
|
||||||
Run this at the terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd part1
|
|
||||||
```
|
|
||||||
|
|
||||||
Now head over to the [README for Part 1](https://github.com/rtfeldman/elm-workshop/tree/master/part1)!
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
[Slides](https://docs.google.com/presentation/d/1sNx5k3_fHwJcgm9QEY1LsMH_TyF5SnnOSDKb8HvFsEU/edit?usp=sharing)
|
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
## Running this workshop
|
||||||
|
|
||||||
|
Hi there! 👋
|
||||||
|
|
||||||
|
I’m [Richard](https://twitter.com/rtfeldman), and I made this workshop. I hope
|
||||||
|
you like it! I’ve [run it for Frontend Masters](https://frontendmasters.com/courses/elm/)
|
||||||
|
as well as [for Pluralsight](https://www.pluralsight.com/courses/elm), and
|
||||||
|
in person at a bunch of conferences. (If you’d like me to run it at a particular
|
||||||
|
conference, have the organizers [ping me](https://twitter.com/rtfeldman)!)
|
||||||
|
|
||||||
|
I released the material I’ve made here under
|
||||||
|
<a rel="license" href="http://creativecommons.org/licenses/by/4.0/">Creative Commons</a>
|
||||||
|
so others can use it to run their own workshops. If that’s you, great! Here’s
|
||||||
|
some info about it that may be helpful in teaching this workshop.
|
||||||
|
|
||||||
|
## Slides
|
||||||
|
|
||||||
|
The [slides](https://docs.google.com/presentation/d/1sNx5k3_fHwJcgm9QEY1LsMH_TyF5SnnOSDKb8HvFsEU/edit?usp=sharing)
|
||||||
|
I use with the workshop include speaker notes. If you’ve seen me give this
|
||||||
|
workshop before, you may notice that I’m not following these notes very closely.
|
||||||
|
|
||||||
|
This is because I made the notes for other teachers, not for myself. In general
|
||||||
|
I prefer not to use speaker notes, but I thought it would be unreasonable to
|
||||||
|
expect other teachers who wanted to use this material to watch hours of video
|
||||||
|
to figure out what words the slides were designed to complement. Writing speaker
|
||||||
|
notes seemed like a nice way to save other teachers a bunch of time, so
|
||||||
|
that’s what I did!
|
||||||
|
|
||||||
|
If you spot any mistakes in the speaker notes, or have any questions about them,
|
||||||
|
please don’t hesitate to ask me in the
|
||||||
|
[`#teaching` channel](https://elmlang.slack.com/messages/C0MF3BQ7K/)
|
||||||
|
on [Elm Slack](http://elmlang.herokuapp.com/).
|
||||||
|
|
||||||
|
## Schedule
|
||||||
|
|
||||||
|
The Intro and Advanced courses are each designed to be a full-day workshop.
|
||||||
|
You may not have a full day to work with, but I figure it’ll be helpful to cover
|
||||||
|
how I use this material. The schedule I follow is typically something like:
|
||||||
|
|
||||||
|
* 30 minutes - help everyone get set up, buffer for late arrivals
|
||||||
|
* 2.5 hours - cover parts 1-4
|
||||||
|
* 1 hour - lunch
|
||||||
|
* 4 hours - cover parts 5-10, with a couple of 10 minute breaks sprinkled in
|
||||||
|
|
||||||
|
Definitely get through part 4 before lunch if possible. Teaching gets harder
|
||||||
|
after lunch because [humans are programmed to get sleepy in the early
|
||||||
|
afternoon](https://www.webmd.com/balance/features/afternoon-energy-boosters#1).
|
||||||
|
Part 4 introduces types in the Intro workshop, and you don’t want to introduce
|
||||||
|
types to sleepy students.
|
||||||
|
|
||||||
|
## Teaching Tips for the Intro Workshop
|
||||||
|
|
||||||
|
I gave a talk about [teaching Elm to beginners](https://www.youtube.com/watch?v=G-GhUxeYc1U)
|
||||||
|
which has a bunch of my thoughts on the subject. The
|
||||||
|
[`#teaching` channel](https://elmlang.slack.com/messages/C0MF3BQ7K/)
|
||||||
|
on [Elm Slack](http://elmlang.herokuapp.com/)
|
||||||
|
is a great place to chat about workshops, exchange tips, etc. Come say hi!
|
||||||
|
|
||||||
|
One big piece of advice I have for this workshop is to
|
||||||
|
**minimize the number of new concepts you introduce**.
|
||||||
|
|
||||||
|
The goal of the workshop is that students will be able to build Elm applications
|
||||||
|
on their own. This requires digesting a **ton** of new information,
|
||||||
|
and piling on even more on top of what’s absolutely necessary is harmful to
|
||||||
|
those students who are already struggling to keep up.
|
||||||
|
|
||||||
|
For example, I only use the term “partial application” to explain that concept.
|
||||||
|
I very deliberately do **not** introduce the term “currying.” If a student asks
|
||||||
|
“is this currying?” I give a short answer like “Yep!” and move on quickly.
|
||||||
|
|
||||||
|
Why not mention it, though? What could it hurt to throw that in?
|
||||||
|
|
||||||
|
Students who have been cruising through material might easily absorb this info
|
||||||
|
and move on, but imagine a student who’s really struggled to keep up.
|
||||||
|
Imagine them thinking “wait, I already don’t understand partial application -
|
||||||
|
and now I need to learn currying too? What’s the difference? *Is* there a
|
||||||
|
difference? Should I raise my hand and ask, or is that a stupid question?
|
||||||
|
Argh, this is so frustrating!”
|
||||||
|
|
||||||
|
It can feel shameful to admit publicly that you don’t understand what the
|
||||||
|
teacher just covered. Especially when everyone else seems to have gotten it.
|
||||||
|
Double especially if you’re sitting near that one student who’s always raising
|
||||||
|
their hand and asking stuff like “so it’s an applicative functor, right?”
|
||||||
|
They make it sound like this is the easiest stuff in the world! Are they
|
||||||
|
going to laugh if you raise your hand and say “I still don’t get why
|
||||||
|
`(List.map negate)` compiles. Can you explain how that works again?”
|
||||||
|
|
||||||
|
If a student is struggling, they generally won’t say so out loud. You won’t
|
||||||
|
find out until you see the mistakes they make on the exercises. Just know that
|
||||||
|
some students in every class will struggle, and that extraneous info that’s a
|
||||||
|
nice-to-have for students who are cruising through the workshop may be
|
||||||
|
detrimental to those who are barely hanging in there.
|
||||||
|
|
||||||
|
Remember, the goal is that students will be able to build Elm applications on
|
||||||
|
their own. **Minimize the number of new concepts you introduce** so you minimize
|
||||||
|
the chance that they’ll get overwhelmed and give up before reaching that goal!
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
Advanced Elm Workshop
|
||||||
|
=====================
|
||||||
|
|
||||||
|
If you haven't already, follow the [Getting Started instructions](https://github.com/rtfeldman/elm-0.19-workshop/blob/master/README.md
|
||||||
|
) at the root of this repository, then continue here!
|
||||||
|
|
||||||
|
## Start the server
|
||||||
|
|
||||||
|
We'll be running a local server for our Elm UI to use. Let's get it set up.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
cd advanced/server
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
If the server started up successfully, you should see
|
||||||
|
`> moleculer-runner services` at the end of your terminal.
|
||||||
|
|
||||||
|
We're going to leave this server running and not touch it again for the duration
|
||||||
|
of the workshop, so **don't close it** until the workshop is over!
|
||||||
|
|
||||||
|
## Build the Elm UI
|
||||||
|
|
||||||
|
Leave the existing terminal running, and open a **second** terminal.
|
||||||
|
|
||||||
|
In the new termnal, `cd` into the `elm-0.19-workshop/advanced/server/` directory again.
|
||||||
|
|
||||||
|
Then run this to build the Elm code for the first time:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
elm make src/Main.elm --output=../server/public/elm.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify your setup
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000)
|
||||||
|
in your browser. You should see this in it:
|
||||||
|
|
||||||
|
<img width="375" alt="A screenshot showing “You’re all set!”" src="https://user-images.githubusercontent.com/1094080/39399636-63605a72-4aef-11e8-82bc-2b94e85369d1.png">
|
||||||
|
|
||||||
|
If things aren’t working, the instructor will be happy to help!
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
* [Elm in Action](https://www.manning.com/books/elm-in-action?a_aid=elm_in_action&a_bid=b15edc5c), a book by [Richard Feldman](https://twitter.com/rtfeldman), creator of this workshop
|
||||||
|
* [Official Elm Guide](https://guide.elm-lang.org/) by [Evan Czaplicki](https://twitter.com/czaplic), creator of Elm
|
||||||
|
* [Elm Slack](http://elmlang.herokuapp.com/) - amazingly helpful chat community. People in [the `#beginners` channel](https://elmlang.slack.com/messages/C192T0Q1E/) are happy to answer questions!
|
||||||
|
* [Elm Discourse](https://discourse.elm-lang.org/) - for longer-form discussions.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Part 1
|
||||||
|
|
||||||
|
To build everything, `cd` into this `part1/` directory and run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
elm make src/Main.elm --output=../server/public/elm.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
## Exercise
|
||||||
|
|
||||||
|
Open `src/Viewer/Cred.elm` in your editor and resolve the TODOs there.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"elm-version": "0.19.0",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"NoRedInk/elm-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-date-strings": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/parser": "1.0.0",
|
||||||
|
"elm/regex": "1.0.0",
|
||||||
|
"elm/virtual-dom": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test-dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"elm-explorations/test": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/random": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
module Api exposing (addServerError, decodeErrors, url)
|
||||||
|
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline as Pipeline exposing (optional)
|
||||||
|
import Url.Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URL
|
||||||
|
|
||||||
|
|
||||||
|
{-| Get a URL to the Conduit API.
|
||||||
|
-}
|
||||||
|
url : List String -> String
|
||||||
|
url paths =
|
||||||
|
-- NOTE: Url.Builder takes care of percent-encoding special URL characters.
|
||||||
|
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
|
||||||
|
Url.Builder.relative ("api" :: paths) []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ERRORS
|
||||||
|
|
||||||
|
|
||||||
|
addServerError : List String -> List String
|
||||||
|
addServerError list =
|
||||||
|
"Server error" :: list
|
||||||
|
|
||||||
|
|
||||||
|
{-| Many API endpoints include an "errors" field in their BadStatus responses.
|
||||||
|
-}
|
||||||
|
decodeErrors : Http.Error -> List String
|
||||||
|
decodeErrors error =
|
||||||
|
case error of
|
||||||
|
Http.BadStatus response ->
|
||||||
|
response.body
|
||||||
|
|> decodeString (field "errors" errorsDecoder)
|
||||||
|
|> Result.withDefault [ "Server error" ]
|
||||||
|
|
||||||
|
err ->
|
||||||
|
[ "Server error" ]
|
||||||
|
|
||||||
|
|
||||||
|
errorsDecoder : Decoder (List String)
|
||||||
|
errorsDecoder =
|
||||||
|
Decode.keyValuePairs (Decode.list Decode.string)
|
||||||
|
|> Decode.map (List.concatMap fromPair)
|
||||||
|
|
||||||
|
|
||||||
|
fromPair : ( String, List String ) -> List String
|
||||||
|
fromPair ( field, errors ) =
|
||||||
|
List.map (\error -> field ++ " " ++ error) errors
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
module Article
|
||||||
|
exposing
|
||||||
|
( Article
|
||||||
|
, Full
|
||||||
|
, Preview
|
||||||
|
, author
|
||||||
|
, body
|
||||||
|
, favorite
|
||||||
|
, favoriteButton
|
||||||
|
, fetch
|
||||||
|
, fromPreview
|
||||||
|
, fullDecoder
|
||||||
|
, mapAuthor
|
||||||
|
, metadata
|
||||||
|
, previewDecoder
|
||||||
|
, slug
|
||||||
|
, unfavorite
|
||||||
|
, unfavoriteButton
|
||||||
|
, url
|
||||||
|
)
|
||||||
|
|
||||||
|
{-| The interface to the Article data structure.
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
- The Article type itself
|
||||||
|
- Ways to make HTTP requests to retrieve and modify articles
|
||||||
|
- Ways to access information about an article
|
||||||
|
- Converting between various types
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article.Body as Body exposing (Body)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Author exposing (Author)
|
||||||
|
import Html exposing (Attribute, Html, i)
|
||||||
|
import Html.Attributes exposing (class)
|
||||||
|
import Html.Events exposing (stopPropagationOn)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, hardcoded, required)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Markdown
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Username as Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
{-| An article, optionally with an article body.
|
||||||
|
|
||||||
|
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
|
||||||
|
consider the difference between the "view individual article" page (which
|
||||||
|
renders one article, including its body) and the "article feed" -
|
||||||
|
which displays multiple articles, but without bodies.
|
||||||
|
|
||||||
|
This definition for `Article` means we can write:
|
||||||
|
|
||||||
|
viewArticle : Article Full -> Html msg
|
||||||
|
viewFeed : List (Article Preview) -> Html msg
|
||||||
|
|
||||||
|
This indicates that `viewArticle` requires an article _with a `body` present_,
|
||||||
|
wereas `viewFeed` accepts articles with no bodies. (We could also have written
|
||||||
|
it as `List (Article a)` to specify that feeds can accept either articles that
|
||||||
|
have `body` present or not. Either work, given that feeds do not attempt to
|
||||||
|
read the `body` field from articles.)
|
||||||
|
|
||||||
|
This is an important distinction, because in Request.Article, the `feed`
|
||||||
|
function produces `List (Article Preview)` because the API does not return bodies.
|
||||||
|
Those articles are useful to the feed, but not to the individual article view.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Article a
|
||||||
|
= Article Internals a
|
||||||
|
|
||||||
|
|
||||||
|
{-| Metadata about the article - its title, description, and so on.
|
||||||
|
|
||||||
|
Importantly, this module's public API exposes a way to read this metadata, but
|
||||||
|
not to alter it. This is read-only information!
|
||||||
|
|
||||||
|
If we find ourselves using any particular piece of metadata often,
|
||||||
|
for example `title`, we could expose a convenience function like this:
|
||||||
|
|
||||||
|
Article.title : Article a -> String
|
||||||
|
|
||||||
|
If you like, it's totally reasonable to expose a function like that for every one
|
||||||
|
of these fields!
|
||||||
|
|
||||||
|
(Okay, to be completely honest, exposing one function per field is how I prefer
|
||||||
|
to do it, and that's how I originally wrote this module. However, I'm aware that
|
||||||
|
this code base has become a common reference point for beginners, and I think it
|
||||||
|
is _extremely important_ that slapping some "getters and setters" on a record
|
||||||
|
does not become a habit for anyone who is getting started with Elm. The whole
|
||||||
|
point of making the Article type opaque is to create guarantees through
|
||||||
|
_selectively choosing boundaries_ around it. If you aren't selective about
|
||||||
|
where those boundaries are, and instead expose a "getter and setter" for every
|
||||||
|
field in the record, the result is an API with no more guarantees than if you'd
|
||||||
|
exposed the entire record directly! It is so important to me that beginners not
|
||||||
|
fall into the terrible "getters and setters" trap that I've exposed this
|
||||||
|
Metadata record instead of exposing a single function for each of its fields,
|
||||||
|
as I did originally. This record is not a bad way to do it, by any means,
|
||||||
|
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
|
||||||
|
See commit c2640ae3abd60262cdaafe6adee3f41d84cd85c3 for how it looked before.
|
||||||
|
)
|
||||||
|
|
||||||
|
-}
|
||||||
|
type alias Metadata =
|
||||||
|
{ description : String
|
||||||
|
, title : String
|
||||||
|
, tags : List String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, favorited : Bool
|
||||||
|
, favoritesCount : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ slug : Slug
|
||||||
|
, author : Author
|
||||||
|
, metadata : Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Preview
|
||||||
|
= Preview
|
||||||
|
|
||||||
|
|
||||||
|
type Full
|
||||||
|
= Full Body
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
author : Article a -> Author
|
||||||
|
author (Article internals _) =
|
||||||
|
internals.author
|
||||||
|
|
||||||
|
|
||||||
|
metadata : Article a -> Metadata
|
||||||
|
metadata (Article internals _) =
|
||||||
|
internals.metadata
|
||||||
|
|
||||||
|
|
||||||
|
slug : Article a -> Slug
|
||||||
|
slug (Article internals _) =
|
||||||
|
internals.slug
|
||||||
|
|
||||||
|
|
||||||
|
body : Article Full -> Body
|
||||||
|
body (Article _ (Full extraInfo)) =
|
||||||
|
extraInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is the only way you can transform an existing article:
|
||||||
|
you can change its author (e.g. to follow or unfollow them).
|
||||||
|
All other article data necessarily comes from the server!
|
||||||
|
|
||||||
|
We can tell this for sure by looking at the types of the exposed functions
|
||||||
|
in this module.
|
||||||
|
|
||||||
|
-}
|
||||||
|
mapAuthor : (Author -> Author) -> Article a -> Article a
|
||||||
|
mapAuthor transform (Article info extras) =
|
||||||
|
Article { info | author = transform info.author } extras
|
||||||
|
|
||||||
|
|
||||||
|
fromPreview : Body -> Article Preview -> Article Full
|
||||||
|
fromPreview newBody (Article info Preview) =
|
||||||
|
Article info (Full newBody)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
previewDecoder : Maybe Cred -> Decoder (Article Preview)
|
||||||
|
previewDecoder maybeCred =
|
||||||
|
Decode.succeed Article
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> hardcoded Preview
|
||||||
|
|
||||||
|
|
||||||
|
fullDecoder : Maybe Cred -> Decoder (Article Full)
|
||||||
|
fullDecoder maybeCred =
|
||||||
|
Decode.succeed Article
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> required "body" (Decode.map Full Body.decoder)
|
||||||
|
|
||||||
|
|
||||||
|
internalsDecoder : Maybe Cred -> Decoder Internals
|
||||||
|
internalsDecoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "slug" Slug.decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> custom metadataDecoder
|
||||||
|
|
||||||
|
|
||||||
|
metadataDecoder : Decoder Metadata
|
||||||
|
metadataDecoder =
|
||||||
|
Decode.succeed Metadata
|
||||||
|
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|
||||||
|
|> required "title" Decode.string
|
||||||
|
|> required "tagList" (Decode.list Decode.string)
|
||||||
|
|> required "createdAt" Timestamp.iso8601Decoder
|
||||||
|
|> required "favorited" Decode.bool
|
||||||
|
|> required "favoritesCount" Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SINGLE
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Maybe Cred -> Slug -> Http.Request (Article Full)
|
||||||
|
fetch maybeCred articleSlug =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
fullDecoder maybeCred
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
url articleSlug []
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect expect
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FAVORITE
|
||||||
|
|
||||||
|
|
||||||
|
favorite : Slug -> Cred -> Http.Request (Article Preview)
|
||||||
|
favorite articleSlug cred =
|
||||||
|
buildFavorite HttpBuilder.post articleSlug cred
|
||||||
|
|
||||||
|
|
||||||
|
unfavorite : Slug -> Cred -> Http.Request (Article Preview)
|
||||||
|
unfavorite articleSlug cred =
|
||||||
|
buildFavorite HttpBuilder.delete articleSlug cred
|
||||||
|
|
||||||
|
|
||||||
|
buildFavorite :
|
||||||
|
(String -> RequestBuilder a)
|
||||||
|
-> Slug
|
||||||
|
-> Cred
|
||||||
|
-> Http.Request (Article Preview)
|
||||||
|
buildFavorite builderFromUrl articleSlug cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
previewDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
builderFromUrl (url articleSlug [ "favorite" ])
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is a "build your own element" API.
|
||||||
|
|
||||||
|
You pass it some configuration, followed by a `List (Attribute msg)` and a
|
||||||
|
`List (Html msg)`, just like any standard Html element.
|
||||||
|
|
||||||
|
-}
|
||||||
|
favoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
|
||||||
|
favoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
unfavoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
|
||||||
|
unfavoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
toggleFavoriteButton :
|
||||||
|
String
|
||||||
|
-> msg
|
||||||
|
-> List (Attribute msg)
|
||||||
|
-> List (Html msg)
|
||||||
|
-> Html msg
|
||||||
|
toggleFavoriteButton classStr msg attrs kids =
|
||||||
|
Html.button
|
||||||
|
(class classStr :: onClickStopPropagation msg :: attrs)
|
||||||
|
(i [ class "ion-heart" ] [] :: kids)
|
||||||
|
|
||||||
|
|
||||||
|
onClickStopPropagation : msg -> Attribute msg
|
||||||
|
onClickStopPropagation msg =
|
||||||
|
stopPropagationOn "click"
|
||||||
|
(Decode.succeed ( msg, True ))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URLS
|
||||||
|
|
||||||
|
|
||||||
|
url : Slug -> List String -> String
|
||||||
|
url articleSlug paths =
|
||||||
|
allArticlesUrl (Slug.toString articleSlug :: paths)
|
||||||
|
|
||||||
|
|
||||||
|
allArticlesUrl : List String -> String
|
||||||
|
allArticlesUrl paths =
|
||||||
|
Api.url ("articles" :: paths)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
module Article.Body exposing (Body, MarkdownString, decoder, toHtml, toMarkdownString)
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Markdown
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Body
|
||||||
|
= Body MarkdownString
|
||||||
|
|
||||||
|
|
||||||
|
{-| Internal use only. I want to remind myself that the string inside Body contains markdown.
|
||||||
|
-}
|
||||||
|
type alias MarkdownString =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CONVERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
toHtml : Body -> List (Attribute msg) -> Html msg
|
||||||
|
toHtml (Body markdown) attributes =
|
||||||
|
Markdown.toHtml attributes markdown
|
||||||
|
|
||||||
|
|
||||||
|
toMarkdownString : Body -> MarkdownString
|
||||||
|
toMarkdownString (Body markdown) =
|
||||||
|
markdown
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Body
|
||||||
|
decoder =
|
||||||
|
Decode.map Body Decode.string
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
module Article.Comment
|
||||||
|
exposing
|
||||||
|
( Comment
|
||||||
|
, author
|
||||||
|
, body
|
||||||
|
, createdAt
|
||||||
|
, delete
|
||||||
|
, id
|
||||||
|
, list
|
||||||
|
, post
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Author exposing (Author)
|
||||||
|
import CommentId exposing (CommentId)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Comment
|
||||||
|
= Comment Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ id : CommentId
|
||||||
|
, body : String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, author : Author
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
id : Comment -> CommentId
|
||||||
|
id (Comment comment) =
|
||||||
|
comment.id
|
||||||
|
|
||||||
|
|
||||||
|
body : Comment -> String
|
||||||
|
body (Comment comment) =
|
||||||
|
comment.body
|
||||||
|
|
||||||
|
|
||||||
|
createdAt : Comment -> Time.Posix
|
||||||
|
createdAt (Comment comment) =
|
||||||
|
comment.createdAt
|
||||||
|
|
||||||
|
|
||||||
|
author : Comment -> Author
|
||||||
|
author (Comment comment) =
|
||||||
|
comment.author
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LIST
|
||||||
|
|
||||||
|
|
||||||
|
list : Maybe Cred -> Slug -> Http.Request (List Comment)
|
||||||
|
list maybeCred articleSlug =
|
||||||
|
allCommentsUrl articleSlug []
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "comments" (Decode.list (decoder maybeCred))))
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- POST
|
||||||
|
|
||||||
|
|
||||||
|
post : Slug -> String -> Cred -> Http.Request Comment
|
||||||
|
post articleSlug commentBody cred =
|
||||||
|
allCommentsUrl articleSlug []
|
||||||
|
|> HttpBuilder.post
|
||||||
|
|> HttpBuilder.withBody (Http.jsonBody (encodeCommentBody commentBody))
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "comment" (decoder (Just cred))))
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
encodeCommentBody : String -> Value
|
||||||
|
encodeCommentBody str =
|
||||||
|
Encode.object [ ( "comment", Encode.object [ ( "body", Encode.string str ) ] ) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- DELETE
|
||||||
|
|
||||||
|
|
||||||
|
delete : Slug -> CommentId -> Cred -> Http.Request ()
|
||||||
|
delete articleSlug commentId cred =
|
||||||
|
commentUrl articleSlug commentId
|
||||||
|
|> HttpBuilder.delete
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Decoder Comment
|
||||||
|
decoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "id" CommentId.decoder
|
||||||
|
|> required "body" Decode.string
|
||||||
|
|> required "createdAt" Timestamp.iso8601Decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> Decode.map Comment
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URLS
|
||||||
|
|
||||||
|
|
||||||
|
commentUrl : Slug -> CommentId -> String
|
||||||
|
commentUrl articleSlug commentId =
|
||||||
|
allCommentsUrl articleSlug [ CommentId.toString commentId ]
|
||||||
|
|
||||||
|
|
||||||
|
allCommentsUrl : Slug -> List String -> String
|
||||||
|
allCommentsUrl articleSlug paths =
|
||||||
|
Api.url ([ "articles", Slug.toString articleSlug, "comments" ] ++ paths)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
module Article.Feed
|
||||||
|
exposing
|
||||||
|
( Model
|
||||||
|
, Msg
|
||||||
|
, decoder
|
||||||
|
, init
|
||||||
|
, update
|
||||||
|
, viewArticles
|
||||||
|
, viewPagination
|
||||||
|
, viewTabs
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Article.Slug as ArticleSlug exposing (Slug)
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Author
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Profile
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| NOTE: This module has its own Model, view, and update. This is not normal!
|
||||||
|
If you find yourself doing this often, please watch <https://www.youtube.com/watch?v=DoA4Txr4GUs>
|
||||||
|
|
||||||
|
This is the reusable Article Feed that appears on both the Home page as well as
|
||||||
|
on the Profile page. There's a lot of logic here, so it's more convenient to use
|
||||||
|
the heavyweight approach of giving this its own Model, view, and update.
|
||||||
|
|
||||||
|
This means callers must use Html.map and Cmd.map to use this thing, but in
|
||||||
|
this case that's totally worth it because of the amount of logic wrapped up
|
||||||
|
in this thing.
|
||||||
|
|
||||||
|
For every other reusable view in this application, this API would be totally
|
||||||
|
overkill, so we use simpler APIs instead.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Model Internals
|
||||||
|
|
||||||
|
|
||||||
|
{-| This should not be exposed! We want to benefit from the guarantee that only
|
||||||
|
this module can create or alter this model. This way if it ever ends up in
|
||||||
|
a surprising state, we know exactly where to look: this module.
|
||||||
|
-}
|
||||||
|
type alias Internals =
|
||||||
|
{ session : Session
|
||||||
|
, errors : List String
|
||||||
|
, articles : PaginatedList (Article Preview)
|
||||||
|
, isLoading : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> PaginatedList (Article Preview) -> Model
|
||||||
|
init session articles =
|
||||||
|
Model
|
||||||
|
{ session = session
|
||||||
|
, errors = []
|
||||||
|
, articles = articles
|
||||||
|
, isLoading = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
viewArticles : Time.Zone -> Model -> List (Html Msg)
|
||||||
|
viewArticles timeZone (Model { articles, session, errors }) =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
articlesHtml =
|
||||||
|
PaginatedList.values articles
|
||||||
|
|> List.map (viewPreview maybeCred timeZone)
|
||||||
|
in
|
||||||
|
Page.viewErrors ClickedDismissErrors errors :: articlesHtml
|
||||||
|
|
||||||
|
|
||||||
|
viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg
|
||||||
|
viewPreview maybeCred timeZone article =
|
||||||
|
let
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
{ title, description, createdAt } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
author =
|
||||||
|
Article.author article
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
username =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
faveButton =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
let
|
||||||
|
{ favoritesCount, favorited } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
viewButton =
|
||||||
|
if favorited then
|
||||||
|
Article.unfavoriteButton cred (ClickedUnfavorite cred slug)
|
||||||
|
|
||||||
|
else
|
||||||
|
Article.favoriteButton cred (ClickedFavorite cred slug)
|
||||||
|
in
|
||||||
|
viewButton [ class "pull-xs-right" ]
|
||||||
|
[ text (" " ++ String.fromInt favoritesCount) ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text ""
|
||||||
|
in
|
||||||
|
div [ class "article-preview" ]
|
||||||
|
[ div [ class "article-meta" ]
|
||||||
|
[ a [ Route.href (Route.Profile username) ]
|
||||||
|
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view username
|
||||||
|
, Timestamp.view timeZone createdAt
|
||||||
|
]
|
||||||
|
, faveButton
|
||||||
|
]
|
||||||
|
, a [ class "preview-link", Route.href (Route.Article (Article.slug article)) ]
|
||||||
|
[ h1 [] [ text title ]
|
||||||
|
, p [] [ text description ]
|
||||||
|
, span [] [ text "Read more..." ]
|
||||||
|
, ul [ class "tag-list" ]
|
||||||
|
(List.map viewTag (Article.metadata article).tags)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs :
|
||||||
|
List ( String, msg )
|
||||||
|
-> ( String, msg )
|
||||||
|
-> List ( String, msg )
|
||||||
|
-> Html msg
|
||||||
|
viewTabs before selected after =
|
||||||
|
ul [ class "nav nav-pills outline-active" ] <|
|
||||||
|
List.concat
|
||||||
|
[ List.map (viewTab []) before
|
||||||
|
, [ viewTab [ class "active" ] selected ]
|
||||||
|
, List.map (viewTab []) after
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTab : List (Attribute msg) -> ( String, msg ) -> Html msg
|
||||||
|
viewTab attrs ( name, msg ) =
|
||||||
|
li [ class "nav-item" ]
|
||||||
|
[ -- Note: The RealWorld CSS requires an href to work properly.
|
||||||
|
a (class "nav-link" :: onClick msg :: href "" :: attrs)
|
||||||
|
[ text name ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewPagination : (Int -> msg) -> Model -> Html msg
|
||||||
|
viewPagination toMsg (Model feed) =
|
||||||
|
PaginatedList.view toMsg feed.articles
|
||||||
|
|
||||||
|
|
||||||
|
viewTag : String -> Html msg
|
||||||
|
viewTag tagName =
|
||||||
|
li [ class "tag-default tag-pill tag-outline" ] [ text tagName ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDismissErrors
|
||||||
|
| ClickedFavorite Cred Slug
|
||||||
|
| ClickedUnfavorite Cred Slug
|
||||||
|
| CompletedFavorite (Result Http.Error (Article Preview))
|
||||||
|
|
||||||
|
|
||||||
|
update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update maybeCred msg (Model model) =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( Model { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedFavorite cred slug ->
|
||||||
|
fave Article.favorite cred slug model
|
||||||
|
|
||||||
|
ClickedUnfavorite cred slug ->
|
||||||
|
fave Article.unfavorite cred slug model
|
||||||
|
|
||||||
|
CompletedFavorite (Ok article) ->
|
||||||
|
( Model { model | articles = PaginatedList.map (replaceArticle article) model.articles }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFavorite (Err error) ->
|
||||||
|
( Model { model | errors = Api.addServerError model.errors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
replaceArticle : Article a -> Article a -> Article a
|
||||||
|
replaceArticle newArticle oldArticle =
|
||||||
|
if Article.slug newArticle == Article.slug oldArticle then
|
||||||
|
newArticle
|
||||||
|
|
||||||
|
else
|
||||||
|
oldArticle
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Int -> Decoder (PaginatedList (Article Preview))
|
||||||
|
decoder maybeCred resultsPerPage =
|
||||||
|
Decode.succeed PaginatedList.fromList
|
||||||
|
|> required "articlesCount" (pageCountDecoder resultsPerPage)
|
||||||
|
|> required "articles" (Decode.list (Article.previewDecoder maybeCred))
|
||||||
|
|
||||||
|
|
||||||
|
pageCountDecoder : Int -> Decoder Int
|
||||||
|
pageCountDecoder resultsPerPage =
|
||||||
|
Decode.int
|
||||||
|
|> Decode.map (\total -> ceiling (toFloat total / toFloat resultsPerPage))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Internals -> ( Model, Cmd Msg )
|
||||||
|
fave toRequest cred slug model =
|
||||||
|
( Model model
|
||||||
|
, toRequest slug cred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.attempt CompletedFavorite
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
module Article.FeedSources exposing (FeedSources, Source(..), after, before, fromLists, select, selected)
|
||||||
|
|
||||||
|
import Article
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type FeedSources
|
||||||
|
= FeedSources
|
||||||
|
{ before : List Source
|
||||||
|
, selected : Source
|
||||||
|
, after : List Source
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Source
|
||||||
|
= YourFeed Cred
|
||||||
|
| GlobalFeed
|
||||||
|
| TagFeed Tag
|
||||||
|
| FavoritedFeed Username
|
||||||
|
| AuthorFeed Username
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- BUILDING
|
||||||
|
|
||||||
|
|
||||||
|
fromLists : Source -> List Source -> FeedSources
|
||||||
|
fromLists selectedSource afterSources =
|
||||||
|
FeedSources
|
||||||
|
{ before = []
|
||||||
|
, selected = selectedSource
|
||||||
|
, after = afterSources
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SELECTING
|
||||||
|
|
||||||
|
|
||||||
|
select : Source -> FeedSources -> FeedSources
|
||||||
|
select selectedSource (FeedSources sources) =
|
||||||
|
let
|
||||||
|
( newBefore, newAfter ) =
|
||||||
|
(sources.before ++ (sources.selected :: sources.after))
|
||||||
|
-- By design, tags can only be included if they're selected.
|
||||||
|
|> List.filter isNotTag
|
||||||
|
|> splitOn (\source -> source == selectedSource)
|
||||||
|
in
|
||||||
|
FeedSources
|
||||||
|
{ before = List.reverse newBefore
|
||||||
|
, selected = selectedSource
|
||||||
|
, after = List.reverse newAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
splitOn : (Source -> Bool) -> List Source -> ( List Source, List Source )
|
||||||
|
splitOn isSelected sources =
|
||||||
|
let
|
||||||
|
( _, newBefore, newAfter ) =
|
||||||
|
List.foldl (splitOnHelp isSelected) ( False, [], [] ) sources
|
||||||
|
in
|
||||||
|
( newBefore, newAfter )
|
||||||
|
|
||||||
|
|
||||||
|
splitOnHelp : (Source -> Bool) -> Source -> ( Bool, List Source, List Source ) -> ( Bool, List Source, List Source )
|
||||||
|
splitOnHelp isSelected source ( foundSelected, beforeSelected, afterSelected ) =
|
||||||
|
if isSelected source then
|
||||||
|
( True, beforeSelected, afterSelected )
|
||||||
|
|
||||||
|
else if foundSelected then
|
||||||
|
( foundSelected, beforeSelected, source :: afterSelected )
|
||||||
|
|
||||||
|
else
|
||||||
|
( foundSelected, source :: beforeSelected, afterSelected )
|
||||||
|
|
||||||
|
|
||||||
|
isNotTag : Source -> Bool
|
||||||
|
isNotTag currentSource =
|
||||||
|
case currentSource of
|
||||||
|
TagFeed _ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
selected : FeedSources -> Source
|
||||||
|
selected (FeedSources record) =
|
||||||
|
record.selected
|
||||||
|
|
||||||
|
|
||||||
|
before : FeedSources -> List Source
|
||||||
|
before (FeedSources record) =
|
||||||
|
record.before
|
||||||
|
|
||||||
|
|
||||||
|
after : FeedSources -> List Source
|
||||||
|
after (FeedSources record) =
|
||||||
|
record.after
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
module Article.Slug exposing (Slug, decoder, toString, urlParser)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Url.Parser exposing (Parser)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Slug
|
||||||
|
= Slug String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Parser (Slug -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "SLUG" (\str -> Just (Slug str))
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Slug
|
||||||
|
decoder =
|
||||||
|
Decode.map Slug Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : Slug -> String
|
||||||
|
toString (Slug str) =
|
||||||
|
str
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
module Article.Tag exposing (Tag, list, toString)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Tag
|
||||||
|
= Tag String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : Tag -> String
|
||||||
|
toString (Tag slug) =
|
||||||
|
slug
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LIST
|
||||||
|
|
||||||
|
|
||||||
|
list : Http.Request (List Tag)
|
||||||
|
list =
|
||||||
|
Decode.field "tags" (Decode.list decoder)
|
||||||
|
|> Http.get (Api.url [ "tags" ])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Tag
|
||||||
|
decoder =
|
||||||
|
Decode.map Tag Decode.string
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module Asset exposing (Image, defaultAvatar, error, loading, src)
|
||||||
|
|
||||||
|
{-| Assets, such as images, videos, and audio. (We only have images for now.)
|
||||||
|
|
||||||
|
We should never expose asset URLs directly; this module should be in charge of
|
||||||
|
all of them. One source of truth!
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
|
||||||
|
|
||||||
|
type Image
|
||||||
|
= Image String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
error : Image
|
||||||
|
error =
|
||||||
|
image "error.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
loading : Image
|
||||||
|
loading =
|
||||||
|
image "loading.svg"
|
||||||
|
|
||||||
|
|
||||||
|
defaultAvatar : Image
|
||||||
|
defaultAvatar =
|
||||||
|
image "smiley-cyrus.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
image : String -> Image
|
||||||
|
image filename =
|
||||||
|
Image ("/assets/images/" ++ filename)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- USING IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
src : Image -> Attribute msg
|
||||||
|
src (Image url) =
|
||||||
|
Attr.src url
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
module Author
|
||||||
|
exposing
|
||||||
|
( Author(..)
|
||||||
|
, FollowedAuthor
|
||||||
|
, UnfollowedAuthor
|
||||||
|
, decoder
|
||||||
|
, fetch
|
||||||
|
, follow
|
||||||
|
, followButton
|
||||||
|
, profile
|
||||||
|
, requestFollow
|
||||||
|
, requestUnfollow
|
||||||
|
, unfollow
|
||||||
|
, unfollowButton
|
||||||
|
, username
|
||||||
|
, view
|
||||||
|
)
|
||||||
|
|
||||||
|
{-| The author of an Article. It includes a Profile.
|
||||||
|
|
||||||
|
I designed this to make sure the compiler would help me keep these three
|
||||||
|
possibilities straight when displaying follow buttons and such:
|
||||||
|
|
||||||
|
- I'm following this author.
|
||||||
|
- I'm not following this author.
|
||||||
|
- I _can't_ follow this author, because it's me!
|
||||||
|
|
||||||
|
To do this, I defined `Author` a custom type with three variants, one for each
|
||||||
|
of those possibilities.
|
||||||
|
|
||||||
|
I also made separate types for FollowedAuthor and UnfollowedAuthor.
|
||||||
|
They are custom type wrappers around Profile, and thier sole purpose is to
|
||||||
|
help me keep track of which operations are supported.
|
||||||
|
|
||||||
|
For example, consider these functions:
|
||||||
|
|
||||||
|
requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
|
||||||
|
These types help the compiler prevent several mistakes:
|
||||||
|
|
||||||
|
- Displaying a Follow button for an author the user already follows.
|
||||||
|
- Displaying an Unfollow button for an author the user already doesn't follow.
|
||||||
|
- Displaying either button when the author is ourself.
|
||||||
|
|
||||||
|
There are still ways we could mess things up (e.g. make a button that calls Author.unfollow when you click it, but which displays "Follow" to the user) - but this rules out a bunch of potential problems.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Html exposing (Html, a, i, text)
|
||||||
|
import Html.Attributes exposing (attribute, class, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, optional, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author - either the current user, another user we're following, or
|
||||||
|
another user we aren't following.
|
||||||
|
|
||||||
|
These distinctions matter because we can only perform "follow" requests for
|
||||||
|
users we aren't following, we can only perform "unfollow" requests for
|
||||||
|
users we _are_ following, and we can't perform either for ourselves.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Author
|
||||||
|
= IsFollowing FollowedAuthor
|
||||||
|
| IsNotFollowing UnfollowedAuthor
|
||||||
|
| IsViewer Cred Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author we're following.
|
||||||
|
-}
|
||||||
|
type FollowedAuthor
|
||||||
|
= FollowedAuthor Username Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author we're not following.
|
||||||
|
-}
|
||||||
|
type UnfollowedAuthor
|
||||||
|
= UnfollowedAuthor Username Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| Return an Author's username.
|
||||||
|
-}
|
||||||
|
username : Author -> Username
|
||||||
|
username author =
|
||||||
|
case author of
|
||||||
|
IsViewer cred _ ->
|
||||||
|
cred.username
|
||||||
|
|
||||||
|
IsFollowing (FollowedAuthor val _) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsNotFollowing (UnfollowedAuthor val _) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
{-| Return an Author's profile.
|
||||||
|
-}
|
||||||
|
profile : Author -> Profile
|
||||||
|
profile author =
|
||||||
|
case author of
|
||||||
|
IsViewer _ val ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsFollowing (FollowedAuthor _ val) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsNotFollowing (UnfollowedAuthor _ val) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FETCH
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Username -> Maybe Cred -> Http.Request Author
|
||||||
|
fetch uname maybeCred =
|
||||||
|
Api.url [ "profiles", Username.toString uname ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "profile" (decoder maybeCred)))
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FOLLOWING
|
||||||
|
|
||||||
|
|
||||||
|
follow : UnfollowedAuthor -> FollowedAuthor
|
||||||
|
follow (UnfollowedAuthor uname prof) =
|
||||||
|
FollowedAuthor uname prof
|
||||||
|
|
||||||
|
|
||||||
|
unfollow : FollowedAuthor -> UnfollowedAuthor
|
||||||
|
unfollow (FollowedAuthor uname prof) =
|
||||||
|
UnfollowedAuthor uname prof
|
||||||
|
|
||||||
|
|
||||||
|
requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestFollow (UnfollowedAuthor uname _) cred =
|
||||||
|
requestHelp HttpBuilder.post uname cred
|
||||||
|
|
||||||
|
|
||||||
|
requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestUnfollow (FollowedAuthor uname _) cred =
|
||||||
|
requestHelp HttpBuilder.delete uname cred
|
||||||
|
|
||||||
|
|
||||||
|
requestHelp :
|
||||||
|
(String -> RequestBuilder a)
|
||||||
|
-> Username
|
||||||
|
-> Cred
|
||||||
|
-> Http.Request Author
|
||||||
|
requestHelp builderFromUrl uname cred =
|
||||||
|
Api.url [ "profiles", Username.toString uname, "follow" ]
|
||||||
|
|> builderFromUrl
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withExpect (Http.expectJson (Decode.field "profile" (decoder Nothing)))
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
followButton : (Cred -> UnfollowedAuthor -> msg) -> Cred -> UnfollowedAuthor -> Html msg
|
||||||
|
followButton toMsg cred ((UnfollowedAuthor uname _) as author) =
|
||||||
|
toggleFollowButton "Follow"
|
||||||
|
[ "btn-outline-secondary" ]
|
||||||
|
(toMsg cred author)
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
unfollowButton : (Cred -> FollowedAuthor -> msg) -> Cred -> FollowedAuthor -> Html msg
|
||||||
|
unfollowButton toMsg cred ((FollowedAuthor uname _) as author) =
|
||||||
|
toggleFollowButton "Unfollow"
|
||||||
|
[ "btn-secondary" ]
|
||||||
|
(toMsg cred author)
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
toggleFollowButton : String -> List String -> msg -> Username -> Html msg
|
||||||
|
toggleFollowButton txt extraClasses msgWhenClicked uname =
|
||||||
|
let
|
||||||
|
classStr =
|
||||||
|
"btn btn-sm " ++ String.join " " extraClasses ++ " action-btn"
|
||||||
|
|
||||||
|
caption =
|
||||||
|
" " ++ txt ++ " " ++ Username.toString uname
|
||||||
|
in
|
||||||
|
Html.button [ class classStr, onClick msgWhenClicked ]
|
||||||
|
[ i [ class "ion-plus-round" ] []
|
||||||
|
, text caption
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Decoder Author
|
||||||
|
decoder maybeCred =
|
||||||
|
Decode.succeed Tuple.pair
|
||||||
|
|> custom Profile.decoder
|
||||||
|
|> required "username" Username.decoder
|
||||||
|
|> Decode.andThen (decodeFromPair maybeCred)
|
||||||
|
|
||||||
|
|
||||||
|
decodeFromPair : Maybe Cred -> ( Profile, Username ) -> Decoder Author
|
||||||
|
decodeFromPair maybeCred ( prof, uname ) =
|
||||||
|
case maybeCred of
|
||||||
|
Nothing ->
|
||||||
|
-- If you're logged out, you can't be following anyone!
|
||||||
|
Decode.succeed (IsNotFollowing (UnfollowedAuthor uname prof))
|
||||||
|
|
||||||
|
Just cred ->
|
||||||
|
if uname == cred.username then
|
||||||
|
Decode.succeed (IsViewer cred prof)
|
||||||
|
|
||||||
|
else
|
||||||
|
nonViewerDecoder prof uname
|
||||||
|
|
||||||
|
|
||||||
|
nonViewerDecoder : Profile -> Username -> Decoder Author
|
||||||
|
nonViewerDecoder prof uname =
|
||||||
|
Decode.succeed (authorFromFollowing prof uname)
|
||||||
|
|> optional "following" Decode.bool False
|
||||||
|
|
||||||
|
|
||||||
|
authorFromFollowing : Profile -> Username -> Bool -> Author
|
||||||
|
authorFromFollowing prof uname isFollowing =
|
||||||
|
if isFollowing then
|
||||||
|
IsFollowing (FollowedAuthor uname prof)
|
||||||
|
|
||||||
|
else
|
||||||
|
IsNotFollowing (UnfollowedAuthor uname prof)
|
||||||
|
|
||||||
|
|
||||||
|
{-| View an author. We basically render their username and a link to their
|
||||||
|
profile, and that's it.
|
||||||
|
-}
|
||||||
|
view : Username -> Html msg
|
||||||
|
view uname =
|
||||||
|
a [ class "author", Route.href (Route.Profile uname) ]
|
||||||
|
[ Username.toHtml uname ]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Avatar
|
||||||
|
= Avatar (Maybe String)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Avatar
|
||||||
|
decoder =
|
||||||
|
Decode.map Avatar (Decode.nullable Decode.string)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Avatar -> Value
|
||||||
|
encode (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Just url ->
|
||||||
|
Encode.string url
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Encode.null
|
||||||
|
|
||||||
|
|
||||||
|
src : Avatar -> Attribute msg
|
||||||
|
src (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Nothing ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just "" ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just url ->
|
||||||
|
Html.Attributes.src url
|
||||||
|
|
||||||
|
|
||||||
|
toMaybeString : Avatar -> Maybe String
|
||||||
|
toMaybeString (Avatar maybeUrl) =
|
||||||
|
maybeUrl
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module CommentId exposing (CommentId, decoder, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type CommentId
|
||||||
|
= CommentId Int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder CommentId
|
||||||
|
decoder =
|
||||||
|
Decode.map CommentId Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : CommentId -> String
|
||||||
|
toString (CommentId id) =
|
||||||
|
String.fromInt id
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
module Email exposing (Email, decoder, encode, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An email address.
|
||||||
|
|
||||||
|
Having this as a custom type that's separate from String makes certain
|
||||||
|
mistakes impossible. Consider this function:
|
||||||
|
|
||||||
|
updateEmailAddress : Email -> String -> Http.Request
|
||||||
|
updateEmailAddress email password = ...
|
||||||
|
|
||||||
|
(The server needs your password to confirm that you should be allowed
|
||||||
|
to update the email address.)
|
||||||
|
|
||||||
|
Because Email is not a type alias for String, but is instead a separate
|
||||||
|
custom type, it is now impossible to mix up the argument order of the
|
||||||
|
email and the password. If we do, it won't compile!
|
||||||
|
|
||||||
|
If Email were instead defined as `type alias Email = String`, we could
|
||||||
|
call updateEmailAddress password email and it would compile (and never
|
||||||
|
work properly).
|
||||||
|
|
||||||
|
This way, we make it impossible for a bug like that to compile!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Email
|
||||||
|
= Email String
|
||||||
|
|
||||||
|
|
||||||
|
toString : Email -> String
|
||||||
|
toString (Email str) =
|
||||||
|
str
|
||||||
|
|
||||||
|
|
||||||
|
encode : Email -> Value
|
||||||
|
encode (Email str) =
|
||||||
|
Encode.string str
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Email
|
||||||
|
decoder =
|
||||||
|
Decode.map Email Decode.string
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module Loading exposing (error, icon, slowThreshold)
|
||||||
|
|
||||||
|
{-| A loading spinner icon.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes exposing (alt, height, src, width)
|
||||||
|
import Process
|
||||||
|
import Task exposing (Task)
|
||||||
|
|
||||||
|
|
||||||
|
icon : Html msg
|
||||||
|
icon =
|
||||||
|
Html.img [ Asset.src Asset.loading, width 64, height 64, alt "Loading..." ] []
|
||||||
|
|
||||||
|
|
||||||
|
error : String -> Html msg
|
||||||
|
error str =
|
||||||
|
Html.text ("Error loading " ++ str ++ ".")
|
||||||
|
|
||||||
|
|
||||||
|
slowThreshold : Task x ()
|
||||||
|
slowThreshold =
|
||||||
|
Process.sleep 500
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module Log exposing (error)
|
||||||
|
|
||||||
|
{-| This is a placeholder API for how we might do logging through
|
||||||
|
some service like <http://rollbar.com> (which is what we use at work).
|
||||||
|
|
||||||
|
Whenever you see Log.error used in this code base, it means
|
||||||
|
"Something unexpected happened. This is where we would log an
|
||||||
|
error to our server with some diagnostic info so we could investigate
|
||||||
|
what happened later."
|
||||||
|
|
||||||
|
(Since this is outside the scope of the RealWorld spec, and is only
|
||||||
|
a placeholder anyway, I didn't bother making this function accept actual
|
||||||
|
diagnostic info, authentication tokens, etc.)
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
error : Cmd msg
|
||||||
|
error =
|
||||||
|
Cmd.none
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Article.FeedSources as FeedSources
|
||||||
|
import Article.Slug exposing (Slug)
|
||||||
|
import Browser exposing (Document)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Json.Decode as Decode exposing (Value)
|
||||||
|
import Page exposing (Page)
|
||||||
|
import Page.Article as Article
|
||||||
|
import Page.Article.Editor as Editor
|
||||||
|
import Page.Blank as Blank
|
||||||
|
import Page.Home as Home
|
||||||
|
import Page.Login as Login
|
||||||
|
import Page.NotFound as NotFound
|
||||||
|
import Page.Profile as Profile
|
||||||
|
import Page.Register as Register
|
||||||
|
import Page.Settings as Settings
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task
|
||||||
|
import Time
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- WARNING: Based on discussions around how asset management features
|
||||||
|
-- like code splitting and lazy loading have been shaping up, I expect
|
||||||
|
-- most of this file to become unnecessary in a future release of Elm.
|
||||||
|
-- Avoid putting things in here unless there is no alternative!
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Redirect Session
|
||||||
|
| NotFound Session
|
||||||
|
| Home Home.Model
|
||||||
|
| Settings Settings.Model
|
||||||
|
| Login Login.Model
|
||||||
|
| Register Register.Model
|
||||||
|
| Profile Username Profile.Model
|
||||||
|
| Article Article.Model
|
||||||
|
| Editor (Maybe Slug) Editor.Model
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
|
init flags url navKey =
|
||||||
|
changeRouteTo (Route.fromUrl url)
|
||||||
|
(Redirect (Session.decode navKey flags))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Document Msg
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
viewPage page toMsg config =
|
||||||
|
let
|
||||||
|
{ title, body } =
|
||||||
|
Page.view (Session.viewer (toSession model)) page config
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, body = List.map (Html.map toMsg) body
|
||||||
|
}
|
||||||
|
in
|
||||||
|
case model of
|
||||||
|
Redirect _ ->
|
||||||
|
viewPage Page.Other (\_ -> Ignored) Blank.view
|
||||||
|
|
||||||
|
NotFound _ ->
|
||||||
|
viewPage Page.Other (\_ -> Ignored) NotFound.view
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
viewPage Page.Other GotSettingsMsg (Settings.view settings)
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
viewPage Page.Home GotHomeMsg (Home.view home)
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
viewPage Page.Other GotLoginMsg (Login.view login)
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
viewPage Page.Other GotRegisterMsg (Register.view register)
|
||||||
|
|
||||||
|
Profile username profile ->
|
||||||
|
viewPage (Page.Profile username) GotProfileMsg (Profile.view profile)
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
viewPage Page.Other GotArticleMsg (Article.view article)
|
||||||
|
|
||||||
|
Editor Nothing editor ->
|
||||||
|
viewPage Page.NewArticle GotEditorMsg (Editor.view editor)
|
||||||
|
|
||||||
|
Editor (Just _) editor ->
|
||||||
|
viewPage Page.Other GotEditorMsg (Editor.view editor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Ignored
|
||||||
|
| ChangedRoute (Maybe Route)
|
||||||
|
| ChangedUrl Url
|
||||||
|
| ClickedLink Browser.UrlRequest
|
||||||
|
| GotHomeMsg Home.Msg
|
||||||
|
| GotSettingsMsg Settings.Msg
|
||||||
|
| GotLoginMsg Login.Msg
|
||||||
|
| GotRegisterMsg Register.Msg
|
||||||
|
| GotProfileMsg Profile.Msg
|
||||||
|
| GotArticleMsg Article.Msg
|
||||||
|
| GotEditorMsg Editor.Msg
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession page =
|
||||||
|
case page of
|
||||||
|
Redirect session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
NotFound session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Home.toSession home
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
Settings.toSession settings
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Login.toSession login
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
Register.toSession register
|
||||||
|
|
||||||
|
Profile _ profile ->
|
||||||
|
Profile.toSession profile
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
Article.toSession article
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Editor.toSession editor
|
||||||
|
|
||||||
|
|
||||||
|
changeRouteTo : Maybe Route -> Model -> ( Model, Cmd Msg )
|
||||||
|
changeRouteTo maybeRoute model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
toSession model
|
||||||
|
in
|
||||||
|
case maybeRoute of
|
||||||
|
Nothing ->
|
||||||
|
( NotFound session, Cmd.none )
|
||||||
|
|
||||||
|
Just Route.Root ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey session) Route.Home )
|
||||||
|
|
||||||
|
Just Route.Logout ->
|
||||||
|
( model, Session.logout )
|
||||||
|
|
||||||
|
Just Route.NewArticle ->
|
||||||
|
Editor.initNew session
|
||||||
|
|> updateWith (Editor Nothing) GotEditorMsg model
|
||||||
|
|
||||||
|
Just (Route.EditArticle slug) ->
|
||||||
|
Editor.initEdit session slug
|
||||||
|
|> updateWith (Editor (Just slug)) GotEditorMsg model
|
||||||
|
|
||||||
|
Just Route.Settings ->
|
||||||
|
Settings.init session
|
||||||
|
|> updateWith Settings GotSettingsMsg model
|
||||||
|
|
||||||
|
Just Route.Home ->
|
||||||
|
Home.init session
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
Just Route.Login ->
|
||||||
|
Login.init session
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
Just Route.Register ->
|
||||||
|
Register.init session
|
||||||
|
|> updateWith Register GotRegisterMsg model
|
||||||
|
|
||||||
|
Just (Route.Profile username) ->
|
||||||
|
Profile.init session username
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
Just (Route.Article slug) ->
|
||||||
|
Article.init session slug
|
||||||
|
|> updateWith Article GotArticleMsg model
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case ( msg, model ) of
|
||||||
|
( Ignored, _ ) ->
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
( ClickedLink urlRequest, _ ) ->
|
||||||
|
case urlRequest of
|
||||||
|
Browser.Internal url ->
|
||||||
|
case url.fragment of
|
||||||
|
Nothing ->
|
||||||
|
-- If we got a link that didn't include a fragment,
|
||||||
|
-- it's from one of those (href "") attributes that
|
||||||
|
-- we have to include to make the RealWorld CSS work.
|
||||||
|
--
|
||||||
|
-- In an application doing path routing instead of
|
||||||
|
-- fragment-based routing, this entire
|
||||||
|
-- `case url.fragment of` expression this comment
|
||||||
|
-- is inside would be unnecessary.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
Just _ ->
|
||||||
|
( model
|
||||||
|
, Nav.pushUrl (Session.navKey (toSession model)) (Url.toString url)
|
||||||
|
)
|
||||||
|
|
||||||
|
Browser.External href ->
|
||||||
|
( model
|
||||||
|
, Nav.load href
|
||||||
|
)
|
||||||
|
|
||||||
|
( ChangedUrl url, _ ) ->
|
||||||
|
changeRouteTo (Route.fromUrl url) model
|
||||||
|
|
||||||
|
( ChangedRoute route, _ ) ->
|
||||||
|
changeRouteTo route model
|
||||||
|
|
||||||
|
( GotSettingsMsg subMsg, Settings settings ) ->
|
||||||
|
Settings.update subMsg settings
|
||||||
|
|> updateWith Settings GotSettingsMsg model
|
||||||
|
|
||||||
|
( GotLoginMsg subMsg, Login login ) ->
|
||||||
|
Login.update subMsg login
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
( GotRegisterMsg subMsg, Register register ) ->
|
||||||
|
Register.update subMsg register
|
||||||
|
|> updateWith Register GotRegisterMsg model
|
||||||
|
|
||||||
|
( GotHomeMsg subMsg, Home home ) ->
|
||||||
|
Home.update subMsg home
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
( GotProfileMsg subMsg, Profile username profile ) ->
|
||||||
|
Profile.update subMsg profile
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
( GotArticleMsg subMsg, Article article ) ->
|
||||||
|
Article.update subMsg article
|
||||||
|
|> updateWith Article GotArticleMsg model
|
||||||
|
|
||||||
|
( GotEditorMsg subMsg, Editor slug editor ) ->
|
||||||
|
Editor.update subMsg editor
|
||||||
|
|> updateWith (Editor slug) GotEditorMsg model
|
||||||
|
|
||||||
|
( GotSession session, Redirect _ ) ->
|
||||||
|
( Redirect session
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
( _, _ ) ->
|
||||||
|
-- Disregard messages that arrived for the wrong page.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
updateWith : (subModel -> Model) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg )
|
||||||
|
updateWith toModel toMsg model ( subModel, subCmd ) =
|
||||||
|
( toModel subModel
|
||||||
|
, Cmd.map toMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
case model of
|
||||||
|
NotFound _ ->
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
Redirect _ ->
|
||||||
|
Session.changes GotSession (Session.navKey (toSession model))
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
Sub.map GotSettingsMsg (Settings.subscriptions settings)
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Sub.map GotHomeMsg (Home.subscriptions home)
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Sub.map GotLoginMsg (Login.subscriptions login)
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
Sub.map GotRegisterMsg (Register.subscriptions register)
|
||||||
|
|
||||||
|
Profile _ profile ->
|
||||||
|
Sub.map GotProfileMsg (Profile.subscriptions profile)
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
Sub.map GotArticleMsg (Article.subscriptions article)
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Sub.map GotEditorMsg (Editor.subscriptions editor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program Value Model Msg
|
||||||
|
main =
|
||||||
|
Browser.application
|
||||||
|
{ init = init
|
||||||
|
, onUrlChange = ChangedUrl
|
||||||
|
, onUrlRequest = ClickedLink
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
module Page exposing (Page(..), view, viewErrors)
|
||||||
|
|
||||||
|
import Avatar
|
||||||
|
import Browser exposing (Document)
|
||||||
|
import Html exposing (Html, a, button, div, footer, i, img, li, nav, p, span, text, ul)
|
||||||
|
import Html.Attributes exposing (class, classList, href, style)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Profile
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Determines which navbar link (if any) will be rendered as active.
|
||||||
|
|
||||||
|
Note that we don't enumerate every page here, because the navbar doesn't
|
||||||
|
have links for every page. Anything that's not part of the navbar falls
|
||||||
|
under Other.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Page
|
||||||
|
= Other
|
||||||
|
| Home
|
||||||
|
| Login
|
||||||
|
| Register
|
||||||
|
| Settings
|
||||||
|
| Profile Username
|
||||||
|
| NewArticle
|
||||||
|
|
||||||
|
|
||||||
|
{-| Take a page's Html and frames it with a header and footer.
|
||||||
|
|
||||||
|
The caller provides the current user, so we can display in either
|
||||||
|
"signed in" (rendering username) or "signed out" mode.
|
||||||
|
|
||||||
|
isLoading is for determining whether we should show a loading spinner
|
||||||
|
in the header. (This comes up during slow page transitions.)
|
||||||
|
|
||||||
|
-}
|
||||||
|
view : Maybe Viewer -> Page -> { title : String, content : Html msg } -> Document msg
|
||||||
|
view maybeViewer page { title, content } =
|
||||||
|
{ title = title ++ " - Conduit"
|
||||||
|
, body = viewHeader page maybeViewer :: content :: [ viewFooter ]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewHeader : Page -> Maybe Viewer -> Html msg
|
||||||
|
viewHeader page maybeViewer =
|
||||||
|
nav [ class "navbar navbar-light" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ a [ class "navbar-brand", Route.href Route.Home ]
|
||||||
|
[ text "conduit" ]
|
||||||
|
, ul [ class "nav navbar-nav pull-xs-right" ] <|
|
||||||
|
navbarLink page Route.Home [ text "Home" ]
|
||||||
|
:: viewMenu page maybeViewer
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewMenu : Page -> Maybe Viewer -> List (Html msg)
|
||||||
|
viewMenu page maybeViewer =
|
||||||
|
let
|
||||||
|
linkTo =
|
||||||
|
navbarLink page
|
||||||
|
in
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
|
||||||
|
{ username } =
|
||||||
|
cred
|
||||||
|
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Viewer.profile viewer)
|
||||||
|
in
|
||||||
|
[ linkTo Route.NewArticle [ i [ class "ion-compose" ] [], text " New Post" ]
|
||||||
|
, linkTo Route.Settings [ i [ class "ion-gear-a" ] [], text " Settings" ]
|
||||||
|
, linkTo
|
||||||
|
(Route.Profile username)
|
||||||
|
[ img [ class "user-pic", Avatar.src avatar ] []
|
||||||
|
, Username.toHtml username
|
||||||
|
]
|
||||||
|
, linkTo Route.Logout [ text "Sign out" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[ linkTo Route.Login [ text "Sign in" ]
|
||||||
|
, linkTo Route.Register [ text "Sign up" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewFooter : Html msg
|
||||||
|
viewFooter =
|
||||||
|
footer []
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ a [ class "logo-font", href "/" ] [ text "conduit" ]
|
||||||
|
, span [ class "attribution" ]
|
||||||
|
[ text "An interactive learning project from "
|
||||||
|
, a [ href "https://thinkster.io" ] [ text "Thinkster" ]
|
||||||
|
, text ". Code & design licensed under MIT."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
navbarLink : Page -> Route -> List (Html msg) -> Html msg
|
||||||
|
navbarLink page route linkContent =
|
||||||
|
li [ classList [ ( "nav-item", True ), ( "active", isActive page route ) ] ]
|
||||||
|
[ a [ class "nav-link", Route.href route ] linkContent ]
|
||||||
|
|
||||||
|
|
||||||
|
isActive : Page -> Route -> Bool
|
||||||
|
isActive page route =
|
||||||
|
case ( page, route ) of
|
||||||
|
( Home, Route.Home ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Login, Route.Login ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Register, Route.Register ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Settings, Route.Settings ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Profile pageUsername, Route.Profile routeUsername ) ->
|
||||||
|
pageUsername == routeUsername
|
||||||
|
|
||||||
|
( NewArticle, Route.NewArticle ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
|
||||||
|
{-| Render dismissable errors. We use this all over the place!
|
||||||
|
-}
|
||||||
|
viewErrors : msg -> List String -> Html msg
|
||||||
|
viewErrors dismissErrors errors =
|
||||||
|
if List.isEmpty errors then
|
||||||
|
Html.text ""
|
||||||
|
|
||||||
|
else
|
||||||
|
div
|
||||||
|
[ class "error-messages"
|
||||||
|
, style "position" "fixed"
|
||||||
|
, style "top" "0"
|
||||||
|
, style "background" "rgb(250, 250, 250)"
|
||||||
|
, style "padding" "20px"
|
||||||
|
, style "border" "1px solid"
|
||||||
|
]
|
||||||
|
<|
|
||||||
|
List.map (\error -> p [] [ text error ]) errors
|
||||||
|
++ [ button [ onClick dismissErrors ] [ text "Ok" ] ]
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
module Page.Article exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| Viewing an individual article.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Full, Preview)
|
||||||
|
import Article.Body exposing (Body)
|
||||||
|
import Article.Comment as Comment exposing (Comment)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor)
|
||||||
|
import Avatar
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import CommentId exposing (CommentId)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick, onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams)
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, errors : List String
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, comments : Status ( CommentText, List Comment )
|
||||||
|
, article : Status (Article Full)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading
|
||||||
|
| LoadingSlowly
|
||||||
|
| Loaded a
|
||||||
|
| Failed
|
||||||
|
|
||||||
|
|
||||||
|
type CommentText
|
||||||
|
= Editing String
|
||||||
|
| Sending String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> Slug -> ( Model, Cmd Msg )
|
||||||
|
init session slug =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, errors = []
|
||||||
|
, comments = Loading
|
||||||
|
, article = Loading
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Article.fetch maybeCred slug
|
||||||
|
|> Http.send CompletedLoadArticle
|
||||||
|
, Comment.list maybeCred slug
|
||||||
|
|> Http.send CompletedLoadComments
|
||||||
|
, Task.perform GotTimeZone Time.here
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
case model.article of
|
||||||
|
Loaded article ->
|
||||||
|
let
|
||||||
|
{ title } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
author =
|
||||||
|
Article.author article
|
||||||
|
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Author.profile author)
|
||||||
|
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
buttons =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
viewButtons cred article author
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, content =
|
||||||
|
div [ class "article-page" ]
|
||||||
|
[ div [ class "banner" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ h1 [] [ text title ]
|
||||||
|
, div [ class "article-meta" ] <|
|
||||||
|
List.append
|
||||||
|
[ a [ Route.href (Route.Profile (Author.username author)) ]
|
||||||
|
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view (Author.username author)
|
||||||
|
, Timestamp.view model.timeZone (Article.metadata article).createdAt
|
||||||
|
]
|
||||||
|
]
|
||||||
|
buttons
|
||||||
|
, Page.viewErrors ClickedDismissErrors model.errors
|
||||||
|
]
|
||||||
|
]
|
||||||
|
, div [ class "container page" ]
|
||||||
|
[ div [ class "row article-content" ]
|
||||||
|
[ div [ class "col-md-12" ]
|
||||||
|
[ Article.Body.toHtml (Article.body article) [] ]
|
||||||
|
]
|
||||||
|
, hr [] []
|
||||||
|
, div [ class "article-actions" ]
|
||||||
|
[ div [ class "article-meta" ] <|
|
||||||
|
List.append
|
||||||
|
[ a [ Route.href (Route.Profile (Author.username author)) ]
|
||||||
|
[ img [ Avatar.src avatar ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view (Author.username author)
|
||||||
|
, Timestamp.view model.timeZone (Article.metadata article).createdAt
|
||||||
|
]
|
||||||
|
]
|
||||||
|
buttons
|
||||||
|
]
|
||||||
|
, div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-8 offset-md-2" ] <|
|
||||||
|
-- Don't render the comments until the article has loaded!
|
||||||
|
case model.comments of
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Loaded ( commentText, comments ) ->
|
||||||
|
-- Don't let users add comments until they can
|
||||||
|
-- see the existing comments! Otherwise you
|
||||||
|
-- may be about to repeat something that's
|
||||||
|
-- already been said.
|
||||||
|
viewAddComment slug commentText (Session.viewer model.session)
|
||||||
|
:: List.map (viewComment model.timeZone slug) comments
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "comments" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
{ title = "Article", content = text "" }
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
{ title = "Article", content = Loading.icon }
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
{ title = "Article", content = Loading.error "article" }
|
||||||
|
|
||||||
|
|
||||||
|
viewAddComment : Slug -> CommentText -> Maybe Viewer -> Html Msg
|
||||||
|
viewAddComment slug commentText maybeViewer =
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Viewer.profile viewer)
|
||||||
|
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
|
||||||
|
( commentStr, buttonAttrs ) =
|
||||||
|
case commentText of
|
||||||
|
Editing str ->
|
||||||
|
( str, [] )
|
||||||
|
|
||||||
|
Sending str ->
|
||||||
|
( str, [ disabled True ] )
|
||||||
|
in
|
||||||
|
Html.form [ class "card comment-form", onSubmit (ClickedPostComment cred slug) ]
|
||||||
|
[ div [ class "card-block" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Write a comment..."
|
||||||
|
, attribute "rows" "3"
|
||||||
|
, onInput EnteredCommentText
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div [ class "card-footer" ]
|
||||||
|
[ img [ class "comment-author-img", Avatar.src avatar ] []
|
||||||
|
, button
|
||||||
|
(class "btn btn-sm btn-primary" :: buttonAttrs)
|
||||||
|
[ text "Post Comment" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
p []
|
||||||
|
[ a [ Route.href Route.Login ] [ text "Sign in" ]
|
||||||
|
, text " or "
|
||||||
|
, a [ Route.href Route.Register ] [ text "sign up" ]
|
||||||
|
, text " to comment."
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewButtons : Cred -> Article Full -> Author -> List (Html Msg)
|
||||||
|
viewButtons cred article author =
|
||||||
|
case author of
|
||||||
|
IsFollowing followedAuthor ->
|
||||||
|
[ Author.unfollowButton ClickedUnfollow cred followedAuthor
|
||||||
|
, text " "
|
||||||
|
, favoriteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
IsNotFollowing unfollowedAuthor ->
|
||||||
|
[ Author.followButton ClickedFollow cred unfollowedAuthor
|
||||||
|
, text " "
|
||||||
|
, favoriteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
IsViewer _ _ ->
|
||||||
|
[ editButton article
|
||||||
|
, text " "
|
||||||
|
, deleteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewComment : Time.Zone -> Slug -> Comment -> Html Msg
|
||||||
|
viewComment timeZone slug comment =
|
||||||
|
let
|
||||||
|
author =
|
||||||
|
Comment.author comment
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
authorUsername =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
deleteCommentButton =
|
||||||
|
case author of
|
||||||
|
IsViewer cred _ ->
|
||||||
|
let
|
||||||
|
msg =
|
||||||
|
ClickedDeleteComment cred slug (Comment.id comment)
|
||||||
|
in
|
||||||
|
span
|
||||||
|
[ class "mod-options"
|
||||||
|
, onClick msg
|
||||||
|
]
|
||||||
|
[ i [ class "ion-trash-a" ] [] ]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- You can't delete other peoples' comments!
|
||||||
|
text ""
|
||||||
|
|
||||||
|
timestamp =
|
||||||
|
Timestamp.format timeZone (Comment.createdAt comment)
|
||||||
|
in
|
||||||
|
div [ class "card" ]
|
||||||
|
[ div [ class "card-block" ]
|
||||||
|
[ p [ class "card-text" ] [ text (Comment.body comment) ] ]
|
||||||
|
, div [ class "card-footer" ]
|
||||||
|
[ a [ class "comment-author", href "" ]
|
||||||
|
[ img [ class "comment-author-img", Avatar.src (Profile.avatar profile) ] []
|
||||||
|
, text " "
|
||||||
|
]
|
||||||
|
, text " "
|
||||||
|
, a [ class "comment-author", Route.href (Route.Profile authorUsername) ]
|
||||||
|
[ text (Username.toString authorUsername) ]
|
||||||
|
, span [ class "date-posted" ] [ text timestamp ]
|
||||||
|
, deleteCommentButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDeleteArticle Cred Slug
|
||||||
|
| ClickedDeleteComment Cred Slug CommentId
|
||||||
|
| ClickedDismissErrors
|
||||||
|
| ClickedFavorite Cred Slug Body
|
||||||
|
| ClickedUnfavorite Cred Slug Body
|
||||||
|
| ClickedFollow Cred UnfollowedAuthor
|
||||||
|
| ClickedUnfollow Cred FollowedAuthor
|
||||||
|
| ClickedPostComment Cred Slug
|
||||||
|
| EnteredCommentText String
|
||||||
|
| CompletedLoadArticle (Result Http.Error (Article Full))
|
||||||
|
| CompletedLoadComments (Result Http.Error (List Comment))
|
||||||
|
| CompletedDeleteArticle (Result Http.Error ())
|
||||||
|
| CompletedDeleteComment CommentId (Result Http.Error ())
|
||||||
|
| CompletedFavoriteChange (Result Http.Error (Article Full))
|
||||||
|
| CompletedFollowChange (Result Http.Error Author)
|
||||||
|
| CompletedPostComment (Result Http.Error Comment)
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedFavorite cred slug body ->
|
||||||
|
( model, fave Article.favorite cred slug body )
|
||||||
|
|
||||||
|
ClickedUnfavorite cred slug body ->
|
||||||
|
( model, fave Article.unfavorite cred slug body )
|
||||||
|
|
||||||
|
CompletedLoadArticle (Ok article) ->
|
||||||
|
( { model | article = Loaded article }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedLoadArticle (Err error) ->
|
||||||
|
( { model | article = Failed }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedLoadComments (Ok comments) ->
|
||||||
|
( { model | comments = Loaded ( Editing "", comments ) }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedLoadComments (Err error) ->
|
||||||
|
( { model | article = Failed }, Log.error )
|
||||||
|
|
||||||
|
CompletedFavoriteChange (Ok newArticle) ->
|
||||||
|
( { model | article = Loaded newArticle }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedFavoriteChange (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedUnfollow cred followedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestUnfollow followedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFollow cred unfollowedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestFollow unfollowedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Ok newAuthor) ->
|
||||||
|
case model.article of
|
||||||
|
Loaded article ->
|
||||||
|
( { model | article = Loaded (Article.mapAuthor (\_ -> newAuthor) article) }, Cmd.none )
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedFollowChange (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredCommentText str ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( Editing _, comments ) ->
|
||||||
|
-- You can only edit comment text once comments have loaded
|
||||||
|
-- successfully, and when the comment is not currently
|
||||||
|
-- being submitted.
|
||||||
|
( { model | comments = Loaded ( Editing str, comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
ClickedPostComment cred slug ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( Editing "", comments ) ->
|
||||||
|
-- No posting empty comments!
|
||||||
|
-- We don't use Log.error here because this isn't an error,
|
||||||
|
-- it just doesn't do anything.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
Loaded ( Editing str, comments ) ->
|
||||||
|
( { model | comments = Loaded ( Sending str, comments ) }
|
||||||
|
, cred
|
||||||
|
|> Comment.post slug str
|
||||||
|
|> Http.send CompletedPostComment
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- Either we have no comment to post, or there's already
|
||||||
|
-- one in the process of being posted, or we don't have
|
||||||
|
-- a valid article, in which case how did we post this?
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedPostComment (Ok comment) ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( _, comments ) ->
|
||||||
|
( { model | comments = Loaded ( Editing "", comment :: comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedPostComment (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedDeleteComment cred slug id ->
|
||||||
|
( model
|
||||||
|
, cred
|
||||||
|
|> Comment.delete slug id
|
||||||
|
|> Http.send (CompletedDeleteComment id)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedDeleteComment id (Ok ()) ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( commentText, comments ) ->
|
||||||
|
( { model | comments = Loaded ( commentText, withoutComment id comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedDeleteComment id (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedDeleteArticle cred slug ->
|
||||||
|
( model
|
||||||
|
, delete slug cred
|
||||||
|
|> Http.send CompletedDeleteArticle
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedDeleteArticle (Ok ()) ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey model.session) Route.Home )
|
||||||
|
|
||||||
|
CompletedDeleteArticle (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
article =
|
||||||
|
case model.article of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|
||||||
|
comments =
|
||||||
|
case model.comments of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | article = article, comments = comments }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
delete : Slug -> Cred -> Http.Request ()
|
||||||
|
delete slug cred =
|
||||||
|
Article.url slug []
|
||||||
|
|> HttpBuilder.delete
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Body -> Cmd Msg
|
||||||
|
fave toRequest cred slug body =
|
||||||
|
toRequest slug cred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.map (Article.fromPreview body)
|
||||||
|
|> Task.attempt CompletedFavoriteChange
|
||||||
|
|
||||||
|
|
||||||
|
withoutComment : CommentId -> List Comment -> List Comment
|
||||||
|
withoutComment id list =
|
||||||
|
List.filter (\comment -> Comment.id comment /= id) list
|
||||||
|
|
||||||
|
|
||||||
|
favoriteButton : Cred -> Article Full -> Html Msg
|
||||||
|
favoriteButton cred article =
|
||||||
|
let
|
||||||
|
{ favoritesCount, favorited } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
body =
|
||||||
|
Article.body article
|
||||||
|
|
||||||
|
kids =
|
||||||
|
[ text (" Favorite Article (" ++ String.fromInt favoritesCount ++ ")") ]
|
||||||
|
in
|
||||||
|
if favorited then
|
||||||
|
Article.unfavoriteButton cred (ClickedUnfavorite cred slug body) [] kids
|
||||||
|
|
||||||
|
else
|
||||||
|
Article.favoriteButton cred (ClickedFavorite cred slug body) [] kids
|
||||||
|
|
||||||
|
|
||||||
|
deleteButton : Cred -> Article a -> Html Msg
|
||||||
|
deleteButton cred article =
|
||||||
|
let
|
||||||
|
msg =
|
||||||
|
ClickedDeleteArticle cred (Article.slug article)
|
||||||
|
in
|
||||||
|
button [ class "btn btn-outline-danger btn-sm", onClick msg ]
|
||||||
|
[ i [ class "ion-trash-a" ] [], text " Delete Article" ]
|
||||||
|
|
||||||
|
|
||||||
|
editButton : Article a -> Html Msg
|
||||||
|
editButton article =
|
||||||
|
a [ class "btn btn-outline-secondary btn-sm", Route.href (Route.EditArticle (Article.slug article)) ]
|
||||||
|
[ i [ class "ion-edit" ] [], text " Edit Article" ]
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
module Page.Article.Editor exposing (Model, Msg, initEdit, initNew, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Full)
|
||||||
|
import Article.Body exposing (Body)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder, type_, value)
|
||||||
|
import Html.Events exposing (onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (withBody, withExpect)
|
||||||
|
import Json.Decode as Decode
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Loading
|
||||||
|
import Page
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, status : Status
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type
|
||||||
|
Status
|
||||||
|
-- Edit Article
|
||||||
|
= Loading Slug
|
||||||
|
| LoadingSlowly Slug
|
||||||
|
| LoadingFailed Slug
|
||||||
|
| Saving Slug Form
|
||||||
|
| Editing Slug (List Problem) Form
|
||||||
|
-- New Article
|
||||||
|
| EditingNew (List Problem) Form
|
||||||
|
| Creating Form
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ title : String
|
||||||
|
, body : String
|
||||||
|
, description : String
|
||||||
|
, tags : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
initNew : Session -> ( Model, Cmd msg )
|
||||||
|
initNew session =
|
||||||
|
( { session = session
|
||||||
|
, status =
|
||||||
|
EditingNew []
|
||||||
|
{ title = ""
|
||||||
|
, body = ""
|
||||||
|
, description = ""
|
||||||
|
, tags = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
initEdit : Session -> Slug -> ( Model, Cmd Msg )
|
||||||
|
initEdit session slug =
|
||||||
|
( { session = session
|
||||||
|
, status = Loading slug
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Article.fetch (Session.cred session) slug
|
||||||
|
|> Http.toTask
|
||||||
|
-- If init fails, store the slug that failed in the msg, so we can
|
||||||
|
-- at least have it later to display the page's title properly!
|
||||||
|
|> Task.mapError (\httpError -> ( slug, httpError ))
|
||||||
|
|> Task.attempt CompletedArticleLoad
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title =
|
||||||
|
case getSlug model.status of
|
||||||
|
Just slug ->
|
||||||
|
"Edit Article - " ++ Slug.toString slug
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"New Article"
|
||||||
|
, content =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
viewAuthenticated cred model
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text "Sign in to edit this article."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewProblems : List Problem -> Html msg
|
||||||
|
viewProblems problems =
|
||||||
|
ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem problems)
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ message ->
|
||||||
|
message
|
||||||
|
|
||||||
|
ServerError message ->
|
||||||
|
message
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
viewAuthenticated : Cred -> Model -> Html Msg
|
||||||
|
viewAuthenticated cred model =
|
||||||
|
let
|
||||||
|
formHtml =
|
||||||
|
case model.status of
|
||||||
|
Loading _ ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Saving slug form ->
|
||||||
|
[ viewForm cred form (editArticleSaveButton [ disabled True ]) ]
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
[ viewForm cred form (newArticleSaveButton [ disabled True ]) ]
|
||||||
|
|
||||||
|
Editing slug problems form ->
|
||||||
|
[ viewProblems problems
|
||||||
|
, viewForm cred form (editArticleSaveButton [])
|
||||||
|
]
|
||||||
|
|
||||||
|
EditingNew problems form ->
|
||||||
|
[ viewProblems problems
|
||||||
|
, viewForm cred form (newArticleSaveButton [])
|
||||||
|
]
|
||||||
|
|
||||||
|
LoadingFailed _ ->
|
||||||
|
[ text "Article failed to load." ]
|
||||||
|
in
|
||||||
|
div [ class "editor-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-10 offset-md-1 col-xs-12" ]
|
||||||
|
formHtml
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Cred -> Form -> Html Msg -> Html Msg
|
||||||
|
viewForm cred fields saveButton =
|
||||||
|
Html.form [ onSubmit (ClickedSave cred) ]
|
||||||
|
[ fieldset []
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Article Title"
|
||||||
|
, onInput EnteredTitle
|
||||||
|
, value fields.title
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "What's this article about?"
|
||||||
|
, onInput EnteredDescription
|
||||||
|
, value fields.description
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Write your article (in markdown)"
|
||||||
|
, attribute "rows" "8"
|
||||||
|
, onInput EnteredBody
|
||||||
|
, value fields.body
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Enter tags"
|
||||||
|
, onInput EnteredTags
|
||||||
|
, value fields.tags
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, saveButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
editArticleSaveButton : List (Attribute msg) -> Html msg
|
||||||
|
editArticleSaveButton extraAttrs =
|
||||||
|
saveArticleButton "Update Article" extraAttrs
|
||||||
|
|
||||||
|
|
||||||
|
newArticleSaveButton : List (Attribute msg) -> Html msg
|
||||||
|
newArticleSaveButton extraAttrs =
|
||||||
|
saveArticleButton "Publish Article" extraAttrs
|
||||||
|
|
||||||
|
|
||||||
|
saveArticleButton : String -> List (Attribute msg) -> Html msg
|
||||||
|
saveArticleButton caption extraAttrs =
|
||||||
|
button (class "btn btn-lg pull-xs-right btn-primary" :: extraAttrs)
|
||||||
|
[ text caption ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedSave Cred
|
||||||
|
| EnteredBody String
|
||||||
|
| EnteredDescription String
|
||||||
|
| EnteredTags String
|
||||||
|
| EnteredTitle String
|
||||||
|
| CompletedCreate (Result Http.Error (Article Full))
|
||||||
|
| CompletedEdit (Result Http.Error (Article Full))
|
||||||
|
| CompletedArticleLoad (Result ( Slug, Http.Error ) (Article Full))
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedSave cred ->
|
||||||
|
model.status
|
||||||
|
|> save cred
|
||||||
|
|> Tuple.mapFirst (\status -> { model | status = status })
|
||||||
|
|
||||||
|
EnteredTitle title ->
|
||||||
|
updateForm (\form -> { form | title = title }) model
|
||||||
|
|
||||||
|
EnteredDescription description ->
|
||||||
|
updateForm (\form -> { form | description = description }) model
|
||||||
|
|
||||||
|
EnteredTags tags ->
|
||||||
|
updateForm (\form -> { form | tags = tags }) model
|
||||||
|
|
||||||
|
EnteredBody body ->
|
||||||
|
updateForm (\form -> { form | body = body }) model
|
||||||
|
|
||||||
|
CompletedCreate (Ok article) ->
|
||||||
|
( model
|
||||||
|
, Route.Article (Article.slug article)
|
||||||
|
|> Route.replaceUrl (Session.navKey model.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedCreate (Err error) ->
|
||||||
|
( { model | status = savingError error model.status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedEdit (Ok article) ->
|
||||||
|
( model
|
||||||
|
, Route.Article (Article.slug article)
|
||||||
|
|> Route.replaceUrl (Session.navKey model.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedEdit (Err error) ->
|
||||||
|
( { model | status = savingError error model.status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedArticleLoad (Err ( slug, error )) ->
|
||||||
|
( { model | status = LoadingFailed slug }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedArticleLoad (Ok article) ->
|
||||||
|
let
|
||||||
|
{ title, description, tags } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
status =
|
||||||
|
Editing (Article.slug article)
|
||||||
|
[]
|
||||||
|
{ title = title
|
||||||
|
, body = Article.Body.toMarkdownString (Article.body article)
|
||||||
|
, description = description
|
||||||
|
, tags = String.join " " tags
|
||||||
|
}
|
||||||
|
in
|
||||||
|
( { model | status = status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
status =
|
||||||
|
case model.status of
|
||||||
|
Loading slug ->
|
||||||
|
LoadingSlowly slug
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | status = status }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
save : Cred -> Status -> ( Status, Cmd Msg )
|
||||||
|
save cred status =
|
||||||
|
case status of
|
||||||
|
Editing slug _ form ->
|
||||||
|
case validate form of
|
||||||
|
Ok validForm ->
|
||||||
|
( Saving slug form
|
||||||
|
, edit slug validForm cred
|
||||||
|
|> Http.send CompletedEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( Editing slug problems form
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EditingNew _ form ->
|
||||||
|
case validate form of
|
||||||
|
Ok validForm ->
|
||||||
|
( Creating form
|
||||||
|
, create validForm cred
|
||||||
|
|> Http.send CompletedCreate
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( EditingNew problems form
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- We're in a state where saving is not allowed.
|
||||||
|
-- We tried to prevent getting here by disabling the Save
|
||||||
|
-- button, but somehow the user got here anyway!
|
||||||
|
--
|
||||||
|
-- If we had an error logging service, we would send
|
||||||
|
-- something to it here!
|
||||||
|
( status, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
savingError : Http.Error -> Status -> Status
|
||||||
|
savingError error status =
|
||||||
|
let
|
||||||
|
problems =
|
||||||
|
[ ServerError "Error saving article" ]
|
||||||
|
in
|
||||||
|
case status of
|
||||||
|
Saving slug form ->
|
||||||
|
Editing slug problems form
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
EditingNew problems form
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
status
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form, if there is one,
|
||||||
|
and returns Cmd.none.
|
||||||
|
|
||||||
|
Useful for recording form fields!
|
||||||
|
|
||||||
|
This could also log errors to the server if we are trying to record things in
|
||||||
|
the form and we don't actually have a form.
|
||||||
|
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
let
|
||||||
|
newModel =
|
||||||
|
case model.status of
|
||||||
|
Loading _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
LoadingFailed _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
Saving slug form ->
|
||||||
|
{ model | status = Saving slug (transform form) }
|
||||||
|
|
||||||
|
Editing slug errors form ->
|
||||||
|
{ model | status = Editing slug errors (transform form) }
|
||||||
|
|
||||||
|
EditingNew errors form ->
|
||||||
|
{ model | status = EditingNew errors (transform form) }
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
{ model | status = Creating (transform form) }
|
||||||
|
in
|
||||||
|
( newModel, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Title
|
||||||
|
| Body
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Title
|
||||||
|
, Body
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Title ->
|
||||||
|
if String.isEmpty form.title then
|
||||||
|
[ "title can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Body ->
|
||||||
|
if String.isEmpty form.body then
|
||||||
|
[ "body can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ title = String.trim form.title
|
||||||
|
, body = String.trim form.body
|
||||||
|
, description = String.trim form.description
|
||||||
|
, tags = String.trim form.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
create : TrimmedForm -> Cred -> Http.Request (Article Full)
|
||||||
|
create (Trimmed form) cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
Article.fullDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
|
||||||
|
article =
|
||||||
|
Encode.object
|
||||||
|
[ ( "title", Encode.string form.title )
|
||||||
|
, ( "description", Encode.string form.description )
|
||||||
|
, ( "body", Encode.string form.body )
|
||||||
|
, ( "tagList", Encode.list Encode.string (tagsFromString form.tags) )
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonBody =
|
||||||
|
Encode.object [ ( "article", article ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.post
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withBody jsonBody
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
tagsFromString : String -> List String
|
||||||
|
tagsFromString str =
|
||||||
|
str
|
||||||
|
|> String.split " "
|
||||||
|
|> List.map String.trim
|
||||||
|
|> List.filter (not << String.isEmpty)
|
||||||
|
|
||||||
|
|
||||||
|
edit : Slug -> TrimmedForm -> Cred -> Http.Request (Article Full)
|
||||||
|
edit articleSlug (Trimmed form) cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
Article.fullDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
|
||||||
|
article =
|
||||||
|
Encode.object
|
||||||
|
[ ( "title", Encode.string form.title )
|
||||||
|
, ( "description", Encode.string form.description )
|
||||||
|
, ( "body", Encode.string form.body )
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonBody =
|
||||||
|
Encode.object [ ( "article", article ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Article.url articleSlug []
|
||||||
|
|> HttpBuilder.put
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withBody jsonBody
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
{-| Used for setting the page's title.
|
||||||
|
-}
|
||||||
|
getSlug : Status -> Maybe Slug
|
||||||
|
getSlug status =
|
||||||
|
case status of
|
||||||
|
Loading slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
LoadingSlowly slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
LoadingFailed slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
Saving slug _ ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
Editing slug _ _ ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
EditingNew _ _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
Creating _ ->
|
||||||
|
Nothing
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module Page.Blank exposing (view)
|
||||||
|
|
||||||
|
import Html exposing (Html)
|
||||||
|
|
||||||
|
|
||||||
|
view : { title : String, content : Html msg }
|
||||||
|
view =
|
||||||
|
{ title = ""
|
||||||
|
, content = Html.text ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
module Page.Home exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| The homepage. You can get here via either the / or /#/ routes.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.Feed as Feed
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Browser.Dom as Dom
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, feedTab : FeedTab
|
||||||
|
, feedPage : Int
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, tags : Status (List Tag)
|
||||||
|
, feed : Status Feed.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading
|
||||||
|
| LoadingSlowly
|
||||||
|
| Loaded a
|
||||||
|
| Failed
|
||||||
|
|
||||||
|
|
||||||
|
type FeedTab
|
||||||
|
= YourFeed Cred
|
||||||
|
| GlobalFeed
|
||||||
|
| TagFeed Tag
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd Msg )
|
||||||
|
init session =
|
||||||
|
let
|
||||||
|
feedTab =
|
||||||
|
case Session.cred session of
|
||||||
|
Just cred ->
|
||||||
|
YourFeed cred
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
GlobalFeed
|
||||||
|
|
||||||
|
loadTags =
|
||||||
|
Http.toTask Tag.list
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, feedTab = feedTab
|
||||||
|
, feedPage = 1
|
||||||
|
, tags = Loading
|
||||||
|
, feed = Loading
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ fetchFeed session feedTab 1
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
, Tag.list
|
||||||
|
|> Http.send CompletedTagsLoad
|
||||||
|
, Task.perform GotTimeZone Time.here
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Conduit"
|
||||||
|
, content =
|
||||||
|
div [ class "home-page" ]
|
||||||
|
[ viewBanner
|
||||||
|
, div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-9" ] <|
|
||||||
|
case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
[ div [ class "feed-toggle" ] <|
|
||||||
|
List.concat
|
||||||
|
[ [ viewTabs
|
||||||
|
(Session.cred model.session)
|
||||||
|
model.feedTab
|
||||||
|
]
|
||||||
|
, Feed.viewArticles model.timeZone feed
|
||||||
|
|> List.map (Html.map GotFeedMsg)
|
||||||
|
, [ Feed.viewPagination ClickedFeedPage feed ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "feed" ]
|
||||||
|
, div [ class "col-md-3" ] <|
|
||||||
|
case model.tags of
|
||||||
|
Loaded tags ->
|
||||||
|
[ div [ class "sidebar" ] <|
|
||||||
|
[ p [] [ text "Popular Tags" ]
|
||||||
|
, viewTags tags
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "tags" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewBanner : Html msg
|
||||||
|
viewBanner =
|
||||||
|
div [ class "banner" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ h1 [ class "logo-font" ] [ text "conduit" ]
|
||||||
|
, p [] [ text "A place to share your knowledge." ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TABS
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs : Maybe Cred -> FeedTab -> Html Msg
|
||||||
|
viewTabs maybeCred tab =
|
||||||
|
case tab of
|
||||||
|
YourFeed cred ->
|
||||||
|
Feed.viewTabs [] (yourFeed cred) [ globalFeed ]
|
||||||
|
|
||||||
|
GlobalFeed ->
|
||||||
|
let
|
||||||
|
otherTabs =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
[ yourFeed cred ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
Feed.viewTabs otherTabs globalFeed []
|
||||||
|
|
||||||
|
TagFeed tag ->
|
||||||
|
let
|
||||||
|
otherTabs =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
[ yourFeed cred, globalFeed ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[ globalFeed ]
|
||||||
|
in
|
||||||
|
Feed.viewTabs otherTabs (tagFeed tag) []
|
||||||
|
|
||||||
|
|
||||||
|
yourFeed : Cred -> ( String, Msg )
|
||||||
|
yourFeed cred =
|
||||||
|
( "Your Feed", ClickedTab (YourFeed cred) )
|
||||||
|
|
||||||
|
|
||||||
|
globalFeed : ( String, Msg )
|
||||||
|
globalFeed =
|
||||||
|
( "Global Feed", ClickedTab GlobalFeed )
|
||||||
|
|
||||||
|
|
||||||
|
tagFeed : Tag -> ( String, Msg )
|
||||||
|
tagFeed tag =
|
||||||
|
( "#" ++ Tag.toString tag, ClickedTab (TagFeed tag) )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TAGS
|
||||||
|
|
||||||
|
|
||||||
|
viewTags : List Tag -> Html Msg
|
||||||
|
viewTags tags =
|
||||||
|
div [ class "tag-list" ] (List.map viewTag tags)
|
||||||
|
|
||||||
|
|
||||||
|
viewTag : Tag -> Html Msg
|
||||||
|
viewTag tagName =
|
||||||
|
a
|
||||||
|
[ class "tag-pill tag-default"
|
||||||
|
, onClick (ClickedTag tagName)
|
||||||
|
|
||||||
|
-- The RealWorld CSS requires an href to work properly.
|
||||||
|
, href ""
|
||||||
|
]
|
||||||
|
[ text (Tag.toString tagName) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedTag Tag
|
||||||
|
| ClickedTab FeedTab
|
||||||
|
| ClickedFeedPage Int
|
||||||
|
| CompletedFeedLoad (Result Http.Error Feed.Model)
|
||||||
|
| CompletedTagsLoad (Result Http.Error (List Tag))
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotFeedMsg Feed.Msg
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedTag tag ->
|
||||||
|
let
|
||||||
|
feedTab =
|
||||||
|
TagFeed tag
|
||||||
|
in
|
||||||
|
( { model | feedTab = feedTab }
|
||||||
|
, fetchFeed model.session feedTab 1
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedTab tab ->
|
||||||
|
( { model | feedTab = tab }
|
||||||
|
, fetchFeed model.session tab 1
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFeedPage page ->
|
||||||
|
( { model | feedPage = page }
|
||||||
|
, fetchFeed model.session model.feedTab page
|
||||||
|
|> Task.andThen (\feed -> Task.map (\_ -> feed) scrollToTop)
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFeedLoad (Ok feed) ->
|
||||||
|
( { model | feed = Loaded feed }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedFeedLoad (Err error) ->
|
||||||
|
( { model | feed = Failed }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedTagsLoad (Ok tags) ->
|
||||||
|
( { model | tags = Loaded tags }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedTagsLoad (Err error) ->
|
||||||
|
( { model | tags = Failed }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotFeedMsg subMsg ->
|
||||||
|
case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
let
|
||||||
|
( newFeed, subCmd ) =
|
||||||
|
Feed.update (Session.cred model.session) subMsg feed
|
||||||
|
in
|
||||||
|
( { model | feed = Loaded newFeed }
|
||||||
|
, Cmd.map GotFeedMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }, Cmd.none )
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
feed =
|
||||||
|
case model.feed of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|
||||||
|
tags =
|
||||||
|
case model.tags of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | feed = feed, tags = tags }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
fetchFeed : Session -> FeedTab -> Int -> Task Http.Error Feed.Model
|
||||||
|
fetchFeed session feedTabs page =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
builder =
|
||||||
|
case feedTabs of
|
||||||
|
YourFeed cred ->
|
||||||
|
Api.url [ "articles", "feed" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|
||||||
|
GlobalFeed ->
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|
||||||
|
TagFeed tag ->
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.withQueryParam "tag" (Tag.toString tag)
|
||||||
|
in
|
||||||
|
builder
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Feed.decoder maybeCred articlesPerPage))
|
||||||
|
|> PaginatedList.fromRequestBuilder articlesPerPage page
|
||||||
|
|> Task.map (Feed.init session)
|
||||||
|
|
||||||
|
|
||||||
|
articlesPerPage : Int
|
||||||
|
articlesPerPage =
|
||||||
|
10
|
||||||
|
|
||||||
|
|
||||||
|
scrollToTop : Task x ()
|
||||||
|
scrollToTop =
|
||||||
|
Dom.setViewport 0 0
|
||||||
|
-- It's not worth showing the user anything special if scrolling fails.
|
||||||
|
-- If anything, we'd log this to an error recording service.
|
||||||
|
|> Task.onError (\_ -> Task.succeed ())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
module Page.Login exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| The login page.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, problems : List Problem
|
||||||
|
, form : Form
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{-| Recording validation problems on a per-field basis facilitates displaying
|
||||||
|
them inline next to the field where the error occurred.
|
||||||
|
|
||||||
|
I implemented it this way out of habit, then realized the spec called for
|
||||||
|
displaying all the errors at the top. I thought about simplifying it, but then
|
||||||
|
figured it'd be useful to show how I would normally model this data - assuming
|
||||||
|
the intended UX was to render errors per field.
|
||||||
|
|
||||||
|
(The other part of this is having a view function like this:
|
||||||
|
|
||||||
|
viewFieldErrors : ValidatedField -> List Problem -> Html msg
|
||||||
|
|
||||||
|
...and it filters the list of problems to render only InvalidEntry ones for the
|
||||||
|
given ValidatedField. That way you can call this:
|
||||||
|
|
||||||
|
viewFieldErrors Email problems
|
||||||
|
|
||||||
|
...next to the `email` field, and call `viewFieldErrors Password problems`
|
||||||
|
next to the `password` field, and so on.
|
||||||
|
|
||||||
|
The `LoginError` should be displayed elsewhere, since it doesn't correspond to
|
||||||
|
a particular field.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ email : String
|
||||||
|
, password : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd msg )
|
||||||
|
init session =
|
||||||
|
( { session = session
|
||||||
|
, problems = []
|
||||||
|
, form =
|
||||||
|
{ email = ""
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Login"
|
||||||
|
, content =
|
||||||
|
div [ class "cred-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-6 offset-md-3 col-xs-12" ]
|
||||||
|
[ h1 [ class "text-xs-center" ] [ text "Sign in" ]
|
||||||
|
, p [ class "text-xs-center" ]
|
||||||
|
[ a [ Route.href Route.Register ]
|
||||||
|
[ text "Need an account?" ]
|
||||||
|
]
|
||||||
|
, ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem model.problems)
|
||||||
|
, viewForm model.form
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ str ->
|
||||||
|
str
|
||||||
|
|
||||||
|
ServerError str ->
|
||||||
|
str
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Form -> Html Msg
|
||||||
|
viewForm form =
|
||||||
|
Html.form [ onSubmit SubmittedForm ]
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Email"
|
||||||
|
, onInput EnteredEmail
|
||||||
|
, value form.email
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, type_ "password"
|
||||||
|
, placeholder "Password"
|
||||||
|
, onInput EnteredPassword
|
||||||
|
, value form.password
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button [ class "btn btn-lg btn-primary pull-xs-right" ]
|
||||||
|
[ text "Sign in" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SubmittedForm
|
||||||
|
| EnteredEmail String
|
||||||
|
| EnteredPassword String
|
||||||
|
| CompletedLogin (Result Http.Error Viewer)
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
SubmittedForm ->
|
||||||
|
case validate model.form of
|
||||||
|
Ok validForm ->
|
||||||
|
( { model | problems = [] }
|
||||||
|
, Http.send CompletedLogin (login validForm)
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( { model | problems = problems }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredEmail email ->
|
||||||
|
updateForm (\form -> { form | email = email }) model
|
||||||
|
|
||||||
|
EnteredPassword password ->
|
||||||
|
updateForm (\form -> { form | password = password }) model
|
||||||
|
|
||||||
|
CompletedLogin (Err error) ->
|
||||||
|
let
|
||||||
|
serverErrors =
|
||||||
|
Api.decodeErrors error
|
||||||
|
|> List.map ServerError
|
||||||
|
in
|
||||||
|
( { model | problems = List.append model.problems serverErrors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedLogin (Ok viewer) ->
|
||||||
|
( model
|
||||||
|
, Session.login viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form and returns Cmd.none and
|
||||||
|
Ignored. Useful for recording form fields!
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
( { model | form = transform model.form }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Email
|
||||||
|
| Password
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Email
|
||||||
|
, Password
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Email ->
|
||||||
|
if String.isEmpty form.email then
|
||||||
|
[ "email can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Password ->
|
||||||
|
if String.isEmpty form.password then
|
||||||
|
[ "password can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ email = String.trim form.email
|
||||||
|
, password = String.trim form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
login : TrimmedForm -> Http.Request Viewer
|
||||||
|
login (Trimmed form) =
|
||||||
|
let
|
||||||
|
user =
|
||||||
|
Encode.object
|
||||||
|
[ ( "email", Encode.string form.email )
|
||||||
|
, ( "password", Encode.string form.password )
|
||||||
|
]
|
||||||
|
|
||||||
|
body =
|
||||||
|
Encode.object [ ( "user", user ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Decode.field "user" Viewer.decoder
|
||||||
|
|> Http.post (Api.url [ "users", "login" ]) body
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module Page.NotFound exposing (view)
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Html, div, h1, img, main_, text)
|
||||||
|
import Html.Attributes exposing (alt, class, id, src, tabindex)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : { title : String, content : Html msg }
|
||||||
|
view =
|
||||||
|
{ title = "Page Not Found"
|
||||||
|
, content =
|
||||||
|
main_ [ id "content", class "container", tabindex -1 ]
|
||||||
|
[ h1 [] [ text "Not Found" ]
|
||||||
|
, div [ class "row" ]
|
||||||
|
[ img [ Asset.src Asset.error ] [] ]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
module Page.Profile exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| An Author's profile.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.Feed as Feed
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor)
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, errors : List String
|
||||||
|
, feedTab : FeedTab
|
||||||
|
, feedPage : Int
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, author : Status Author
|
||||||
|
, feed : Status Feed.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type FeedTab
|
||||||
|
= MyArticles
|
||||||
|
| FavoritedArticles
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading Username
|
||||||
|
| LoadingSlowly Username
|
||||||
|
| Loaded a
|
||||||
|
| Failed Username
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> Username -> ( Model, Cmd Msg )
|
||||||
|
init session username =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, errors = []
|
||||||
|
, feedTab = defaultFeedTab
|
||||||
|
, feedPage = 1
|
||||||
|
, author = Loading username
|
||||||
|
, feed = Loading username
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Author.fetch username maybeCred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.mapError (Tuple.pair username)
|
||||||
|
|> Task.attempt CompletedAuthorLoad
|
||||||
|
, fetchFeed session defaultFeedTab username 1
|
||||||
|
, Task.perform GotTimeZone Time.here
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
currentUsername : Model -> Username
|
||||||
|
currentUsername model =
|
||||||
|
case model.author of
|
||||||
|
Loading username ->
|
||||||
|
username
|
||||||
|
|
||||||
|
LoadingSlowly username ->
|
||||||
|
username
|
||||||
|
|
||||||
|
Loaded author ->
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
Failed username ->
|
||||||
|
username
|
||||||
|
|
||||||
|
|
||||||
|
defaultFeedTab : FeedTab
|
||||||
|
defaultFeedTab =
|
||||||
|
MyArticles
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
fetchFeed : Session -> FeedTab -> Username -> Int -> Cmd Msg
|
||||||
|
fetchFeed session feedTabs username page =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
( extraParamName, extraParamVal ) =
|
||||||
|
case feedTabs of
|
||||||
|
MyArticles ->
|
||||||
|
( "author", Username.toString username )
|
||||||
|
|
||||||
|
FavoritedArticles ->
|
||||||
|
( "favorited", Username.toString username )
|
||||||
|
in
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Feed.decoder maybeCred articlesPerPage))
|
||||||
|
|> HttpBuilder.withQueryParam extraParamName extraParamVal
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> PaginatedList.fromRequestBuilder articlesPerPage page
|
||||||
|
|> Task.map (Feed.init session)
|
||||||
|
|> Task.mapError (Tuple.pair username)
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
|
||||||
|
|
||||||
|
articlesPerPage : Int
|
||||||
|
articlesPerPage =
|
||||||
|
5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
title =
|
||||||
|
case model.author of
|
||||||
|
Loaded (IsViewer _ _) ->
|
||||||
|
myProfileTitle
|
||||||
|
|
||||||
|
Loaded ((IsFollowing followedAuthor) as author) ->
|
||||||
|
titleForOther (Author.username author)
|
||||||
|
|
||||||
|
Loaded ((IsNotFollowing unfollowedAuthor) as author) ->
|
||||||
|
titleForOther (Author.username author)
|
||||||
|
|
||||||
|
Loading username ->
|
||||||
|
titleForMe (Session.cred model.session) username
|
||||||
|
|
||||||
|
LoadingSlowly username ->
|
||||||
|
titleForMe (Session.cred model.session) username
|
||||||
|
|
||||||
|
Failed username ->
|
||||||
|
titleForMe (Session.cred model.session) username
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, content =
|
||||||
|
case model.author of
|
||||||
|
Loaded author ->
|
||||||
|
let
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
username =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
followButton =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
case author of
|
||||||
|
IsViewer _ _ ->
|
||||||
|
-- We can't follow ourselves!
|
||||||
|
text ""
|
||||||
|
|
||||||
|
IsFollowing followedAuthor ->
|
||||||
|
Author.unfollowButton ClickedUnfollow cred followedAuthor
|
||||||
|
|
||||||
|
IsNotFollowing unfollowedAuthor ->
|
||||||
|
Author.followButton ClickedFollow cred unfollowedAuthor
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
-- We can't follow if we're logged out
|
||||||
|
text ""
|
||||||
|
in
|
||||||
|
div [ class "profile-page" ]
|
||||||
|
[ Page.viewErrors ClickedDismissErrors model.errors
|
||||||
|
, div [ class "user-info" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-10 offset-md-1" ]
|
||||||
|
[ img [ class "user-img", Avatar.src (Profile.avatar profile) ] []
|
||||||
|
, h4 [] [ Username.toHtml username ]
|
||||||
|
, p [] [ text (Maybe.withDefault "" (Profile.bio profile)) ]
|
||||||
|
, followButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
, case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
div [ class "container" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-10 offset-md-1" ]
|
||||||
|
[ div [ class "articles-toggle" ] <|
|
||||||
|
List.concat
|
||||||
|
[ [ viewTabs model.feedTab ]
|
||||||
|
, Feed.viewArticles model.timeZone feed
|
||||||
|
|> List.map (Html.map GotFeedMsg)
|
||||||
|
, [ Feed.viewPagination ClickedFeedPage feed ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
Loading.icon
|
||||||
|
|
||||||
|
Failed _ ->
|
||||||
|
Loading.error "feed"
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
Loading.icon
|
||||||
|
|
||||||
|
Failed _ ->
|
||||||
|
Loading.error "profile"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PAGE TITLE
|
||||||
|
|
||||||
|
|
||||||
|
titleForOther : Username -> String
|
||||||
|
titleForOther otherUsername =
|
||||||
|
"Profile — " ++ Username.toString otherUsername
|
||||||
|
|
||||||
|
|
||||||
|
titleForMe : Maybe Cred -> Username -> String
|
||||||
|
titleForMe maybeCred username =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
if username == cred.username then
|
||||||
|
myProfileTitle
|
||||||
|
|
||||||
|
else
|
||||||
|
defaultTitle
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
defaultTitle
|
||||||
|
|
||||||
|
|
||||||
|
myProfileTitle : String
|
||||||
|
myProfileTitle =
|
||||||
|
"My Profile"
|
||||||
|
|
||||||
|
|
||||||
|
defaultTitle : String
|
||||||
|
defaultTitle =
|
||||||
|
"Profile"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TABS
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs : FeedTab -> Html Msg
|
||||||
|
viewTabs tab =
|
||||||
|
case tab of
|
||||||
|
MyArticles ->
|
||||||
|
Feed.viewTabs [] myArticles [ favoritedArticles ]
|
||||||
|
|
||||||
|
FavoritedArticles ->
|
||||||
|
Feed.viewTabs [ myArticles ] favoritedArticles []
|
||||||
|
|
||||||
|
|
||||||
|
myArticles : ( String, Msg )
|
||||||
|
myArticles =
|
||||||
|
( "My Articles", ClickedTab MyArticles )
|
||||||
|
|
||||||
|
|
||||||
|
favoritedArticles : ( String, Msg )
|
||||||
|
favoritedArticles =
|
||||||
|
( "Favorited Articles", ClickedTab FavoritedArticles )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDismissErrors
|
||||||
|
| ClickedFollow Cred UnfollowedAuthor
|
||||||
|
| ClickedUnfollow Cred FollowedAuthor
|
||||||
|
| ClickedTab FeedTab
|
||||||
|
| ClickedFeedPage Int
|
||||||
|
| CompletedFollowChange (Result Http.Error Author)
|
||||||
|
| CompletedAuthorLoad (Result ( Username, Http.Error ) Author)
|
||||||
|
| CompletedFeedLoad (Result ( Username, Http.Error ) Feed.Model)
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotFeedMsg Feed.Msg
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedUnfollow cred followedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestUnfollow followedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFollow cred unfollowedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestFollow unfollowedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedTab tab ->
|
||||||
|
( { model | feedTab = tab }
|
||||||
|
, fetchFeed model.session tab (currentUsername model) 1
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFeedPage page ->
|
||||||
|
( { model | feedPage = page }
|
||||||
|
, fetchFeed model.session model.feedTab (currentUsername model) page
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Ok newAuthor) ->
|
||||||
|
( { model | author = Loaded newAuthor }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Err error) ->
|
||||||
|
( model
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedAuthorLoad (Ok author) ->
|
||||||
|
( { model | author = Loaded author }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedAuthorLoad (Err ( username, err )) ->
|
||||||
|
( { model | author = Failed username }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFeedLoad (Ok feed) ->
|
||||||
|
( { model | feed = Loaded feed }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFeedLoad (Err ( username, err )) ->
|
||||||
|
( { model | feed = Failed username }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotFeedMsg subMsg ->
|
||||||
|
case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
let
|
||||||
|
( newFeed, subCmd ) =
|
||||||
|
Feed.update (Session.cred model.session) subMsg feed
|
||||||
|
in
|
||||||
|
( { model | feed = Loaded newFeed }
|
||||||
|
, Cmd.map GotFeedMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
Failed _ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
feed =
|
||||||
|
case model.feed of
|
||||||
|
Loading username ->
|
||||||
|
LoadingSlowly username
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | feed = feed }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
module Page.Register exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, problems : List Problem
|
||||||
|
, form : Form
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ email : String
|
||||||
|
, username : String
|
||||||
|
, password : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd msg )
|
||||||
|
init session =
|
||||||
|
( { session = session
|
||||||
|
, problems = []
|
||||||
|
, form =
|
||||||
|
{ email = ""
|
||||||
|
, username = ""
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Register"
|
||||||
|
, content =
|
||||||
|
div [ class "cred-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-6 offset-md-3 col-xs-12" ]
|
||||||
|
[ h1 [ class "text-xs-center" ] [ text "Sign up" ]
|
||||||
|
, p [ class "text-xs-center" ]
|
||||||
|
[ a [ Route.href Route.Login ]
|
||||||
|
[ text "Have an account?" ]
|
||||||
|
]
|
||||||
|
, ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem model.problems)
|
||||||
|
, viewForm model.form
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Form -> Html Msg
|
||||||
|
viewForm form =
|
||||||
|
Html.form [ onSubmit SubmittedForm ]
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Username"
|
||||||
|
, onInput EnteredUsername
|
||||||
|
, value form.username
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Email"
|
||||||
|
, onInput EnteredEmail
|
||||||
|
, value form.email
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, type_ "password"
|
||||||
|
, placeholder "Password"
|
||||||
|
, onInput EnteredPassword
|
||||||
|
, value form.password
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button [ class "btn btn-lg btn-primary pull-xs-right" ]
|
||||||
|
[ text "Sign up" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ str ->
|
||||||
|
str
|
||||||
|
|
||||||
|
ServerError str ->
|
||||||
|
str
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SubmittedForm
|
||||||
|
| EnteredEmail String
|
||||||
|
| EnteredUsername String
|
||||||
|
| EnteredPassword String
|
||||||
|
| CompletedRegister (Result Http.Error Viewer)
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
SubmittedForm ->
|
||||||
|
case validate model.form of
|
||||||
|
Ok validForm ->
|
||||||
|
( { model | problems = [] }
|
||||||
|
, Http.send CompletedRegister (register validForm)
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( { model | problems = problems }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredUsername username ->
|
||||||
|
updateForm (\form -> { form | username = username }) model
|
||||||
|
|
||||||
|
EnteredEmail email ->
|
||||||
|
updateForm (\form -> { form | email = email }) model
|
||||||
|
|
||||||
|
EnteredPassword password ->
|
||||||
|
updateForm (\form -> { form | password = password }) model
|
||||||
|
|
||||||
|
CompletedRegister (Err error) ->
|
||||||
|
let
|
||||||
|
serverErrors =
|
||||||
|
Api.decodeErrors error
|
||||||
|
|> List.map ServerError
|
||||||
|
in
|
||||||
|
( { model | problems = List.append model.problems serverErrors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedRegister (Ok viewer) ->
|
||||||
|
( model
|
||||||
|
, Session.login viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form and returns Cmd.none and
|
||||||
|
Ignored. Useful for recording form fields!
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
( { model | form = transform model.form }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Username
|
||||||
|
| Email
|
||||||
|
| Password
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Username
|
||||||
|
, Email
|
||||||
|
, Password
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Username ->
|
||||||
|
if String.isEmpty form.username then
|
||||||
|
[ "username can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Email ->
|
||||||
|
if String.isEmpty form.email then
|
||||||
|
[ "email can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Password ->
|
||||||
|
if String.isEmpty form.password then
|
||||||
|
[ "password can't be blank." ]
|
||||||
|
|
||||||
|
else if String.length form.password < Viewer.minPasswordChars then
|
||||||
|
[ "password must be at least " ++ String.fromInt Viewer.minPasswordChars ++ " characters long." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ username = String.trim form.username
|
||||||
|
, email = String.trim form.email
|
||||||
|
, password = String.trim form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
register : TrimmedForm -> Http.Request Viewer
|
||||||
|
register (Trimmed form) =
|
||||||
|
let
|
||||||
|
user =
|
||||||
|
Encode.object
|
||||||
|
[ ( "username", Encode.string form.username )
|
||||||
|
, ( "email", Encode.string form.email )
|
||||||
|
, ( "password", Encode.string form.password )
|
||||||
|
]
|
||||||
|
|
||||||
|
body =
|
||||||
|
Encode.object [ ( "user", user ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Decode.field "user" Viewer.decoder
|
||||||
|
|> Http.post (Api.url [ "users" ]) body
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
module Page.Settings exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Avatar
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Email exposing (Email)
|
||||||
|
import Html exposing (Html, button, div, fieldset, h1, input, li, text, textarea, ul)
|
||||||
|
import Html.Attributes exposing (attribute, class, placeholder, type_, value)
|
||||||
|
import Html.Events exposing (onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, list, string)
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Username as Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, problems : List Problem
|
||||||
|
, form : Form
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ avatar : String
|
||||||
|
, bio : String
|
||||||
|
, email : String
|
||||||
|
, username : String
|
||||||
|
, password : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd msg )
|
||||||
|
init session =
|
||||||
|
( { session = session
|
||||||
|
, problems = []
|
||||||
|
, form =
|
||||||
|
case Session.viewer session of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
profile =
|
||||||
|
Viewer.profile viewer
|
||||||
|
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
in
|
||||||
|
{ avatar = Maybe.withDefault "" (Avatar.toMaybeString (Profile.avatar profile))
|
||||||
|
, email = Email.toString (Viewer.email viewer)
|
||||||
|
, bio = Maybe.withDefault "" (Profile.bio profile)
|
||||||
|
, username = Username.toString cred.username
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
-- It's fine to store a blank form here. You won't be
|
||||||
|
-- able to submit it if you're not logged in anyway.
|
||||||
|
{ avatar = ""
|
||||||
|
, email = ""
|
||||||
|
, bio = ""
|
||||||
|
, username = ""
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| A form that has been validated. Only the `edit` function uses this. Its
|
||||||
|
purpose is to prevent us from forgetting to validate the form before passing
|
||||||
|
it to `edit`.
|
||||||
|
|
||||||
|
This doesn't create any guarantees that the form was actually validated. If
|
||||||
|
we wanted to do that, we'd need to move the form data into a separate module!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type ValidForm
|
||||||
|
= Valid Form
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Settings"
|
||||||
|
, content =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
div [ class "settings-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-6 offset-md-3 col-xs-12" ]
|
||||||
|
[ h1 [ class "text-xs-center" ] [ text "Your Settings" ]
|
||||||
|
, ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem model.problems)
|
||||||
|
, viewForm cred model.form
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text "Sign in to view your settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Cred -> Form -> Html Msg
|
||||||
|
viewForm cred form =
|
||||||
|
Html.form [ onSubmit (SubmittedForm cred) ]
|
||||||
|
[ fieldset []
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "URL of profile picture"
|
||||||
|
, value form.avatar
|
||||||
|
, onInput EnteredAvatar
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Username"
|
||||||
|
, value form.username
|
||||||
|
, onInput EnteredUsername
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Short bio about you"
|
||||||
|
, attribute "rows" "8"
|
||||||
|
, value form.bio
|
||||||
|
, onInput EnteredBio
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Email"
|
||||||
|
, value form.email
|
||||||
|
, onInput EnteredEmail
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, type_ "password"
|
||||||
|
, placeholder "Password"
|
||||||
|
, value form.password
|
||||||
|
, onInput EnteredPassword
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button
|
||||||
|
[ class "btn btn-lg btn-primary pull-xs-right" ]
|
||||||
|
[ text "Update Settings" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ message ->
|
||||||
|
message
|
||||||
|
|
||||||
|
ServerError message ->
|
||||||
|
message
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SubmittedForm Cred
|
||||||
|
| EnteredEmail String
|
||||||
|
| EnteredUsername String
|
||||||
|
| EnteredPassword String
|
||||||
|
| EnteredBio String
|
||||||
|
| EnteredAvatar String
|
||||||
|
| CompletedSave (Result Http.Error Viewer)
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
SubmittedForm cred ->
|
||||||
|
case validate model.form of
|
||||||
|
Ok validForm ->
|
||||||
|
( { model | problems = [] }
|
||||||
|
, edit cred validForm
|
||||||
|
|> Http.send CompletedSave
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( { model | problems = problems }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredEmail email ->
|
||||||
|
updateForm (\form -> { form | email = email }) model
|
||||||
|
|
||||||
|
EnteredUsername username ->
|
||||||
|
updateForm (\form -> { form | username = username }) model
|
||||||
|
|
||||||
|
EnteredPassword password ->
|
||||||
|
updateForm (\form -> { form | password = password }) model
|
||||||
|
|
||||||
|
EnteredBio bio ->
|
||||||
|
updateForm (\form -> { form | bio = bio }) model
|
||||||
|
|
||||||
|
EnteredAvatar avatar ->
|
||||||
|
updateForm (\form -> { form | avatar = avatar }) model
|
||||||
|
|
||||||
|
CompletedSave (Err error) ->
|
||||||
|
let
|
||||||
|
serverErrors =
|
||||||
|
Api.decodeErrors error
|
||||||
|
|> List.map ServerError
|
||||||
|
in
|
||||||
|
( { model | problems = List.append model.problems serverErrors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedSave (Ok cred) ->
|
||||||
|
( model
|
||||||
|
, Session.login cred
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form and returns Cmd.none and
|
||||||
|
Ignored. Useful for recording form fields!
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
( { model | form = transform model.form }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
|
||||||
|
NOTE: there are no ImageUrl or Bio variants here, because they aren't validated!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Username
|
||||||
|
| Email
|
||||||
|
| Password
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Username
|
||||||
|
, Email
|
||||||
|
, Password
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Username ->
|
||||||
|
if String.isEmpty form.username then
|
||||||
|
[ "username can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Email ->
|
||||||
|
if String.isEmpty form.email then
|
||||||
|
[ "email can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Password ->
|
||||||
|
let
|
||||||
|
passwordLength =
|
||||||
|
String.length form.password
|
||||||
|
in
|
||||||
|
if passwordLength > 0 && passwordLength < Viewer.minPasswordChars then
|
||||||
|
[ "password must be at least " ++ String.fromInt Viewer.minPasswordChars ++ " characters long." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ avatar = String.trim form.avatar
|
||||||
|
, bio = String.trim form.bio
|
||||||
|
, email = String.trim form.email
|
||||||
|
, username = String.trim form.username
|
||||||
|
, password = String.trim form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
{-| This takes a Valid Form as a reminder that it needs to have been validated
|
||||||
|
first.
|
||||||
|
-}
|
||||||
|
edit : Cred -> TrimmedForm -> Http.Request Viewer
|
||||||
|
edit cred (Trimmed form) =
|
||||||
|
let
|
||||||
|
encodedAvatar =
|
||||||
|
case form.avatar of
|
||||||
|
"" ->
|
||||||
|
Encode.null
|
||||||
|
|
||||||
|
avatar ->
|
||||||
|
Encode.string avatar
|
||||||
|
|
||||||
|
updates =
|
||||||
|
[ ( "username", Encode.string form.username )
|
||||||
|
, ( "email", Encode.string form.email )
|
||||||
|
, ( "bio", Encode.string form.bio )
|
||||||
|
, ( "image", encodedAvatar )
|
||||||
|
]
|
||||||
|
|
||||||
|
encodedUser =
|
||||||
|
Encode.object <|
|
||||||
|
case form.password of
|
||||||
|
"" ->
|
||||||
|
updates
|
||||||
|
|
||||||
|
password ->
|
||||||
|
( "password", Encode.string password ) :: updates
|
||||||
|
|
||||||
|
body =
|
||||||
|
Encode.object [ ( "user", encodedUser ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
|
||||||
|
expect =
|
||||||
|
Decode.field "user" Viewer.decoder
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
Api.url [ "user" ]
|
||||||
|
|> HttpBuilder.put
|
||||||
|
|> HttpBuilder.withExpect expect
|
||||||
|
|> HttpBuilder.withBody body
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
nothingIfEmpty : String -> Maybe String
|
||||||
|
nothingIfEmpty str =
|
||||||
|
if String.isEmpty str then
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
else
|
||||||
|
Just str
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
module PaginatedList exposing (PaginatedList, fromList, fromRequestBuilder, map, page, total, values, view)
|
||||||
|
|
||||||
|
import Html exposing (Html, a, li, text, ul)
|
||||||
|
import Html.Attributes exposing (class, classList, href)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Task exposing (Task)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type PaginatedList a
|
||||||
|
= PaginatedList
|
||||||
|
{ values : List a
|
||||||
|
, total : Int
|
||||||
|
, page : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
values : PaginatedList a -> List a
|
||||||
|
values (PaginatedList info) =
|
||||||
|
info.values
|
||||||
|
|
||||||
|
|
||||||
|
total : PaginatedList a -> Int
|
||||||
|
total (PaginatedList info) =
|
||||||
|
info.total
|
||||||
|
|
||||||
|
|
||||||
|
page : PaginatedList a -> Int
|
||||||
|
page (PaginatedList info) =
|
||||||
|
info.page
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
fromList : Int -> List a -> PaginatedList a
|
||||||
|
fromList totalCount list =
|
||||||
|
PaginatedList { values = list, total = totalCount, page = 1 }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
map : (a -> a) -> PaginatedList a -> PaginatedList a
|
||||||
|
map transform (PaginatedList info) =
|
||||||
|
PaginatedList { info | values = List.map transform info.values }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : (Int -> msg) -> PaginatedList a -> Html msg
|
||||||
|
view toMsg (PaginatedList info) =
|
||||||
|
let
|
||||||
|
viewPageLink currentPage =
|
||||||
|
pageLink toMsg currentPage (currentPage == info.page)
|
||||||
|
in
|
||||||
|
if info.total > 1 then
|
||||||
|
List.range 1 info.total
|
||||||
|
|> List.map viewPageLink
|
||||||
|
|> ul [ class "pagination" ]
|
||||||
|
|
||||||
|
else
|
||||||
|
Html.text ""
|
||||||
|
|
||||||
|
|
||||||
|
pageLink : (Int -> msg) -> Int -> Bool -> Html msg
|
||||||
|
pageLink toMsg targetPage isActive =
|
||||||
|
li [ classList [ ( "page-item", True ), ( "active", isActive ) ] ]
|
||||||
|
[ a
|
||||||
|
[ class "page-link"
|
||||||
|
, onClick (toMsg targetPage)
|
||||||
|
|
||||||
|
-- The RealWorld CSS requires an href to work properly.
|
||||||
|
, href ""
|
||||||
|
]
|
||||||
|
[ text (String.fromInt targetPage) ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
{-| I considered accepting a record here so I don't mess up the argument order.
|
||||||
|
-}
|
||||||
|
fromRequestBuilder :
|
||||||
|
Int
|
||||||
|
-> Int
|
||||||
|
-> RequestBuilder (PaginatedList a)
|
||||||
|
-> Task Http.Error (PaginatedList a)
|
||||||
|
fromRequestBuilder resultsPerPage pageNumber builder =
|
||||||
|
let
|
||||||
|
offset =
|
||||||
|
(pageNumber - 1) * resultsPerPage
|
||||||
|
|
||||||
|
params =
|
||||||
|
[ ( "limit", String.fromInt resultsPerPage )
|
||||||
|
, ( "offset", String.fromInt offset )
|
||||||
|
]
|
||||||
|
in
|
||||||
|
builder
|
||||||
|
|> HttpBuilder.withQueryParams params
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.map (\(PaginatedList info) -> PaginatedList { info | page = pageNumber })
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
module Profile exposing (Profile, avatar, bio, decoder)
|
||||||
|
|
||||||
|
{-| A user's profile - potentially your own!
|
||||||
|
|
||||||
|
Contrast with Cred, which is the currently signed-in user.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Profile
|
||||||
|
= Profile Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ bio : Maybe String
|
||||||
|
, avatar : Avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
bio : Profile -> Maybe String
|
||||||
|
bio (Profile info) =
|
||||||
|
info.bio
|
||||||
|
|
||||||
|
|
||||||
|
avatar : Profile -> Avatar
|
||||||
|
avatar (Profile info) =
|
||||||
|
info.avatar
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Profile
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "bio" (Decode.nullable Decode.string)
|
||||||
|
|> required "image" Avatar.decoder
|
||||||
|
|> Decode.map Profile
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
module Route exposing (Route(..), fromUrl, href, replaceUrl)
|
||||||
|
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ROUTING
|
||||||
|
|
||||||
|
|
||||||
|
type Route
|
||||||
|
= Home
|
||||||
|
| Root
|
||||||
|
| Login
|
||||||
|
| Logout
|
||||||
|
| Register
|
||||||
|
| Settings
|
||||||
|
| Article Slug
|
||||||
|
| Profile Username
|
||||||
|
| NewArticle
|
||||||
|
| EditArticle Slug
|
||||||
|
|
||||||
|
|
||||||
|
parser : Parser (Route -> a) a
|
||||||
|
parser =
|
||||||
|
oneOf
|
||||||
|
[ Parser.map Home Parser.top
|
||||||
|
, Parser.map Login (s "login")
|
||||||
|
, Parser.map Logout (s "logout")
|
||||||
|
, Parser.map Settings (s "settings")
|
||||||
|
, Parser.map Profile (s "profile" </> Username.urlParser)
|
||||||
|
, Parser.map Register (s "register")
|
||||||
|
, Parser.map Article (s "article" </> Slug.urlParser)
|
||||||
|
, Parser.map NewArticle (s "editor")
|
||||||
|
, Parser.map EditArticle (s "editor" </> Slug.urlParser)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PUBLIC HELPERS
|
||||||
|
|
||||||
|
|
||||||
|
href : Route -> Attribute msg
|
||||||
|
href targetRoute =
|
||||||
|
Attr.href (routeToString targetRoute)
|
||||||
|
|
||||||
|
|
||||||
|
replaceUrl : Nav.Key -> Route -> Cmd msg
|
||||||
|
replaceUrl key route =
|
||||||
|
Nav.replaceUrl key (routeToString route)
|
||||||
|
|
||||||
|
|
||||||
|
fromUrl : Url -> Maybe Route
|
||||||
|
fromUrl url =
|
||||||
|
-- The RealWorld spec treats the fragment like a path.
|
||||||
|
-- This makes it *literally* the path, so we can proceed
|
||||||
|
-- with parsing as if it had been a normal path all along.
|
||||||
|
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||||
|
|> Parser.parse parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
routeToString : Route -> String
|
||||||
|
routeToString page =
|
||||||
|
let
|
||||||
|
pieces =
|
||||||
|
case page of
|
||||||
|
Home ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Root ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Login ->
|
||||||
|
[ "login" ]
|
||||||
|
|
||||||
|
Logout ->
|
||||||
|
[ "logout" ]
|
||||||
|
|
||||||
|
Register ->
|
||||||
|
[ "register" ]
|
||||||
|
|
||||||
|
Settings ->
|
||||||
|
[ "settings" ]
|
||||||
|
|
||||||
|
Article slug ->
|
||||||
|
[ "article", Slug.toString slug ]
|
||||||
|
|
||||||
|
Profile username ->
|
||||||
|
[ "profile", Username.toString username ]
|
||||||
|
|
||||||
|
NewArticle ->
|
||||||
|
[ "editor" ]
|
||||||
|
|
||||||
|
EditArticle slug ->
|
||||||
|
[ "editor", Slug.toString slug ]
|
||||||
|
in
|
||||||
|
"#/" ++ String.join "/" pieces
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
port module Session
|
||||||
|
exposing
|
||||||
|
( Session
|
||||||
|
, changes
|
||||||
|
, cred
|
||||||
|
, decode
|
||||||
|
, login
|
||||||
|
, logout
|
||||||
|
, navKey
|
||||||
|
, viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Session
|
||||||
|
= LoggedIn Nav.Key Viewer
|
||||||
|
| Guest Nav.Key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
viewer : Session -> Maybe Viewer
|
||||||
|
viewer session =
|
||||||
|
case session of
|
||||||
|
LoggedIn _ val ->
|
||||||
|
Just val
|
||||||
|
|
||||||
|
Guest _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
cred : Session -> Maybe Cred
|
||||||
|
cred session =
|
||||||
|
case session of
|
||||||
|
LoggedIn _ val ->
|
||||||
|
Just (Viewer.cred val)
|
||||||
|
|
||||||
|
Guest _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
navKey : Session -> Nav.Key
|
||||||
|
navKey session =
|
||||||
|
case session of
|
||||||
|
LoggedIn key _ ->
|
||||||
|
key
|
||||||
|
|
||||||
|
Guest key ->
|
||||||
|
key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LOGIN
|
||||||
|
|
||||||
|
|
||||||
|
login : Viewer -> Cmd msg
|
||||||
|
login newViewer =
|
||||||
|
Viewer.encode newViewer
|
||||||
|
|> Encode.encode 0
|
||||||
|
|> Just
|
||||||
|
|> storeSession
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LOGOUT
|
||||||
|
|
||||||
|
|
||||||
|
logout : Cmd msg
|
||||||
|
logout =
|
||||||
|
storeSession Nothing
|
||||||
|
|
||||||
|
|
||||||
|
port storeSession : Maybe String -> Cmd msg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CHANGES
|
||||||
|
|
||||||
|
|
||||||
|
changes : (Session -> msg) -> Nav.Key -> Sub msg
|
||||||
|
changes toMsg key =
|
||||||
|
onSessionChange (\val -> toMsg (decode key val))
|
||||||
|
|
||||||
|
|
||||||
|
port onSessionChange : (Value -> msg) -> Sub msg
|
||||||
|
|
||||||
|
|
||||||
|
decode : Nav.Key -> Value -> Session
|
||||||
|
decode key value =
|
||||||
|
-- It's stored in localStorage as a JSON String;
|
||||||
|
-- first decode the Value as a String, then
|
||||||
|
-- decode that String as JSON.
|
||||||
|
case
|
||||||
|
Decode.decodeValue Decode.string value
|
||||||
|
|> Result.andThen (Decode.decodeString Viewer.decoder)
|
||||||
|
|> Result.toMaybe
|
||||||
|
of
|
||||||
|
Just decodedViewer ->
|
||||||
|
LoggedIn key decodedViewer
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Guest key
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
module Timestamp exposing (format, iso8601Decoder, view)
|
||||||
|
|
||||||
|
import Html exposing (Html, span, text)
|
||||||
|
import Html.Attributes exposing (class)
|
||||||
|
import Iso8601
|
||||||
|
import Json.Decode as Decode exposing (Decoder, fail, succeed)
|
||||||
|
import Time exposing (Month(..))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Time.Zone -> Time.Posix -> Html msg
|
||||||
|
view timeZone timestamp =
|
||||||
|
span [ class "date" ] [ text (format timeZone timestamp) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- DECODE
|
||||||
|
|
||||||
|
|
||||||
|
{-| Decode an ISO-8601 date string.
|
||||||
|
-}
|
||||||
|
iso8601Decoder : Decoder Time.Posix
|
||||||
|
iso8601Decoder =
|
||||||
|
Decode.string
|
||||||
|
|> Decode.andThen fromString
|
||||||
|
|
||||||
|
|
||||||
|
fromString : String -> Decoder Time.Posix
|
||||||
|
fromString str =
|
||||||
|
case Iso8601.toTime str of
|
||||||
|
Ok successValue ->
|
||||||
|
succeed successValue
|
||||||
|
|
||||||
|
Err _ ->
|
||||||
|
fail ("Invalid date: " ++ str)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORMAT
|
||||||
|
|
||||||
|
|
||||||
|
{-| Format a timestamp as a String, like so:
|
||||||
|
|
||||||
|
"February 14, 2018"
|
||||||
|
|
||||||
|
For more complex date formatting scenarios, here's a nice package:
|
||||||
|
<https://package.elm-lang.org/packages/ryannhg/date-format/latest/>
|
||||||
|
|
||||||
|
-}
|
||||||
|
format : Time.Zone -> Time.Posix -> String
|
||||||
|
format zone time =
|
||||||
|
let
|
||||||
|
month =
|
||||||
|
case Time.toMonth zone time of
|
||||||
|
Jan ->
|
||||||
|
"January"
|
||||||
|
|
||||||
|
Feb ->
|
||||||
|
"February"
|
||||||
|
|
||||||
|
Mar ->
|
||||||
|
"March"
|
||||||
|
|
||||||
|
Apr ->
|
||||||
|
"April"
|
||||||
|
|
||||||
|
May ->
|
||||||
|
"May"
|
||||||
|
|
||||||
|
Jun ->
|
||||||
|
"June"
|
||||||
|
|
||||||
|
Jul ->
|
||||||
|
"July"
|
||||||
|
|
||||||
|
Aug ->
|
||||||
|
"August"
|
||||||
|
|
||||||
|
Sep ->
|
||||||
|
"September"
|
||||||
|
|
||||||
|
Oct ->
|
||||||
|
"October"
|
||||||
|
|
||||||
|
Nov ->
|
||||||
|
"November"
|
||||||
|
|
||||||
|
Dec ->
|
||||||
|
"December"
|
||||||
|
|
||||||
|
day =
|
||||||
|
String.fromInt (Time.toDay zone time)
|
||||||
|
|
||||||
|
year =
|
||||||
|
String.fromInt (Time.toYear zone time)
|
||||||
|
in
|
||||||
|
month ++ " " ++ day ++ ", " ++ year
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Username exposing (Username, decoder, encode, toHtml, toString, urlParser)
|
||||||
|
|
||||||
|
import Html exposing (Html)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Url.Parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Username
|
||||||
|
= Username String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Username
|
||||||
|
decoder =
|
||||||
|
Decode.map Username Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Username -> Value
|
||||||
|
encode (Username username) =
|
||||||
|
Encode.string username
|
||||||
|
|
||||||
|
|
||||||
|
toString : Username -> String
|
||||||
|
toString (Username username) =
|
||||||
|
username
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Url.Parser.Parser (Username -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "USERNAME" (\str -> Just (Username str))
|
||||||
|
|
||||||
|
|
||||||
|
toHtml : Username -> Html msg
|
||||||
|
toHtml (Username username) =
|
||||||
|
Html.text username
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
module Viewer exposing (Viewer, cred, decoder, email, encode, minPasswordChars, profile)
|
||||||
|
|
||||||
|
{-| The logged-in user currently viewing this page.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Email exposing (Email)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Viewer
|
||||||
|
= Viewer Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ cred : Cred
|
||||||
|
, profile : Profile
|
||||||
|
, email : Email
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
cred : Viewer -> Cred
|
||||||
|
cred (Viewer info) =
|
||||||
|
info.cred
|
||||||
|
|
||||||
|
|
||||||
|
profile : Viewer -> Profile
|
||||||
|
profile (Viewer info) =
|
||||||
|
info.profile
|
||||||
|
|
||||||
|
|
||||||
|
email : Viewer -> Email
|
||||||
|
email (Viewer info) =
|
||||||
|
info.email
|
||||||
|
|
||||||
|
|
||||||
|
{-| Passwords must be at least this many characters long!
|
||||||
|
-}
|
||||||
|
minPasswordChars : Int
|
||||||
|
minPasswordChars =
|
||||||
|
6
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
encode : Viewer -> Value
|
||||||
|
encode (Viewer info) =
|
||||||
|
Encode.object
|
||||||
|
[ ( "email", Email.encode info.email )
|
||||||
|
, ( "username", Username.encode info.cred.username )
|
||||||
|
, ( "image", Avatar.encode (Profile.avatar info.profile) )
|
||||||
|
, ( "token", Cred.encodeToken info.cred )
|
||||||
|
, ( "bio"
|
||||||
|
, case Profile.bio info.profile of
|
||||||
|
Just bio ->
|
||||||
|
Encode.string bio
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Encode.null
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Viewer
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> custom Cred.decoder
|
||||||
|
|> custom Profile.decoder
|
||||||
|
|> required "email" Email.decoder
|
||||||
|
|> Decode.map Viewer
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
module Viewer.Cred exposing (Cred, addHeader, addHeaderIfAvailable, decoder, encodeToken)
|
||||||
|
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withHeader)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Cred
|
||||||
|
= Cred Username String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
username : Cred -> Username
|
||||||
|
username (Cred uname _) =
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Cred
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Cred
|
||||||
|
|> required "username" Username.decoder
|
||||||
|
|> required "token" Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encodeToken : Cred -> Value
|
||||||
|
encodeToken (Cred _ token) =
|
||||||
|
Encode.string token
|
||||||
|
|
||||||
|
|
||||||
|
addHeader : Cred -> RequestBuilder a -> RequestBuilder a
|
||||||
|
addHeader (Cred _ token) builder =
|
||||||
|
builder
|
||||||
|
|> withHeader "authorization" ("Token " ++ token)
|
||||||
|
|
||||||
|
|
||||||
|
addHeaderIfAvailable : Maybe Cred -> RequestBuilder a -> RequestBuilder a
|
||||||
|
addHeaderIfAvailable maybeCred builder =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
addHeader cred builder
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
builder
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Part 2
|
||||||
|
|
||||||
|
To build everything, `cd` into this `part2/` directory and run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
elm make src/Main.elm --output=../server/public/elm.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
## Exercise
|
||||||
|
|
||||||
|
Open `src/Article.elm` in your editor and resolve the TODOs there.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"elm-version": "0.19.0",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"NoRedInk/elm-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-date-strings": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/parser": "1.0.0",
|
||||||
|
"elm/regex": "1.0.0",
|
||||||
|
"elm/virtual-dom": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test-dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"elm-explorations/test": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/random": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
module Api exposing (addServerError, decodeErrors, url)
|
||||||
|
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline as Pipeline exposing (optional)
|
||||||
|
import Url.Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URL
|
||||||
|
|
||||||
|
|
||||||
|
{-| Get a URL to the Conduit API.
|
||||||
|
-}
|
||||||
|
url : List String -> String
|
||||||
|
url paths =
|
||||||
|
-- NOTE: Url.Builder takes care of percent-encoding special URL characters.
|
||||||
|
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
|
||||||
|
Url.Builder.relative ("api" :: paths) []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ERRORS
|
||||||
|
|
||||||
|
|
||||||
|
addServerError : List String -> List String
|
||||||
|
addServerError list =
|
||||||
|
"Server error" :: list
|
||||||
|
|
||||||
|
|
||||||
|
{-| Many API endpoints include an "errors" field in their BadStatus responses.
|
||||||
|
-}
|
||||||
|
decodeErrors : Http.Error -> List String
|
||||||
|
decodeErrors error =
|
||||||
|
case error of
|
||||||
|
Http.BadStatus response ->
|
||||||
|
response.body
|
||||||
|
|> decodeString (field "errors" errorsDecoder)
|
||||||
|
|> Result.withDefault [ "Server error" ]
|
||||||
|
|
||||||
|
err ->
|
||||||
|
[ "Server error" ]
|
||||||
|
|
||||||
|
|
||||||
|
errorsDecoder : Decoder (List String)
|
||||||
|
errorsDecoder =
|
||||||
|
Decode.keyValuePairs (Decode.list Decode.string)
|
||||||
|
|> Decode.map (List.concatMap fromPair)
|
||||||
|
|
||||||
|
|
||||||
|
fromPair : ( String, List String ) -> List String
|
||||||
|
fromPair ( field, errors ) =
|
||||||
|
List.map (\error -> field ++ " " ++ error) errors
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
module Article
|
||||||
|
exposing
|
||||||
|
( Article
|
||||||
|
, Full
|
||||||
|
, Preview
|
||||||
|
, author
|
||||||
|
, body
|
||||||
|
, favorite
|
||||||
|
, favoriteButton
|
||||||
|
, fetch
|
||||||
|
, fromPreview
|
||||||
|
, fullDecoder
|
||||||
|
, mapAuthor
|
||||||
|
, metadata
|
||||||
|
, previewDecoder
|
||||||
|
, slug
|
||||||
|
, unfavorite
|
||||||
|
, unfavoriteButton
|
||||||
|
, url
|
||||||
|
)
|
||||||
|
|
||||||
|
{-| The interface to the Article data structure.
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
- The Article type itself
|
||||||
|
- Ways to make HTTP requests to retrieve and modify articles
|
||||||
|
- Ways to access information about an article
|
||||||
|
- Converting between various types
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article.Body as Body exposing (Body)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Author exposing (Author)
|
||||||
|
import Html exposing (Attribute, Html, i)
|
||||||
|
import Html.Attributes exposing (class)
|
||||||
|
import Html.Events exposing (stopPropagationOn)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, hardcoded, required)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Markdown
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Username as Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
{-| An article, optionally with an article body.
|
||||||
|
|
||||||
|
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
|
||||||
|
consider the difference between the "view individual article" page (which
|
||||||
|
renders one article, including its body) and the "article feed" -
|
||||||
|
which displays multiple articles, but without bodies.
|
||||||
|
|
||||||
|
This definition for `Article` means we can write:
|
||||||
|
|
||||||
|
viewArticle : Article Full -> Html msg
|
||||||
|
viewFeed : List (Article Preview) -> Html msg
|
||||||
|
|
||||||
|
This indicates that `viewArticle` requires an article _with a `body` present_,
|
||||||
|
wereas `viewFeed` accepts articles with no bodies. (We could also have written
|
||||||
|
it as `List (Article a)` to specify that feeds can accept either articles that
|
||||||
|
have `body` present or not. Either work, given that feeds do not attempt to
|
||||||
|
read the `body` field from articles.)
|
||||||
|
|
||||||
|
This is an important distinction, because in Request.Article, the `feed`
|
||||||
|
function produces `List (Article Preview)` because the API does not return bodies.
|
||||||
|
Those articles are useful to the feed, but not to the individual article view.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Article a
|
||||||
|
= Article Internals a
|
||||||
|
|
||||||
|
|
||||||
|
{-| Metadata about the article - its title, description, and so on.
|
||||||
|
|
||||||
|
Importantly, this module's public API exposes a way to read this metadata, but
|
||||||
|
not to alter it. This is read-only information!
|
||||||
|
|
||||||
|
If we find ourselves using any particular piece of metadata often,
|
||||||
|
for example `title`, we could expose a convenience function like this:
|
||||||
|
|
||||||
|
Article.title : Article a -> String
|
||||||
|
|
||||||
|
If you like, it's totally reasonable to expose a function like that for every one
|
||||||
|
of these fields!
|
||||||
|
|
||||||
|
(Okay, to be completely honest, exposing one function per field is how I prefer
|
||||||
|
to do it, and that's how I originally wrote this module. However, I'm aware that
|
||||||
|
this code base has become a common reference point for beginners, and I think it
|
||||||
|
is _extremely important_ that slapping some "getters and setters" on a record
|
||||||
|
does not become a habit for anyone who is getting started with Elm. The whole
|
||||||
|
point of making the Article type opaque is to create guarantees through
|
||||||
|
_selectively choosing boundaries_ around it. If you aren't selective about
|
||||||
|
where those boundaries are, and instead expose a "getter and setter" for every
|
||||||
|
field in the record, the result is an API with no more guarantees than if you'd
|
||||||
|
exposed the entire record directly! It is so important to me that beginners not
|
||||||
|
fall into the terrible "getters and setters" trap that I've exposed this
|
||||||
|
Metadata record instead of exposing a single function for each of its fields,
|
||||||
|
as I did originally. This record is not a bad way to do it, by any means,
|
||||||
|
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
|
||||||
|
See commit c2640ae3abd60262cdaafe6adee3f41d84cd85c3 for how it looked before.
|
||||||
|
)
|
||||||
|
|
||||||
|
-}
|
||||||
|
type alias Metadata =
|
||||||
|
{ description : String
|
||||||
|
, title : String
|
||||||
|
, tags : List String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, favorited : Bool
|
||||||
|
, favoritesCount : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ slug : Slug
|
||||||
|
, author : Author
|
||||||
|
, metadata : Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Preview
|
||||||
|
= Preview
|
||||||
|
|
||||||
|
|
||||||
|
type Full
|
||||||
|
= Full Body
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
author : Article a -> Author
|
||||||
|
author (Article internals _) =
|
||||||
|
internals.author
|
||||||
|
|
||||||
|
|
||||||
|
metadata : Article a -> Metadata
|
||||||
|
metadata (Article internals _) =
|
||||||
|
internals.metadata
|
||||||
|
|
||||||
|
|
||||||
|
slug : Article a -> Slug
|
||||||
|
slug (Article internals _) =
|
||||||
|
internals.slug
|
||||||
|
|
||||||
|
|
||||||
|
body : Article Full -> Body
|
||||||
|
body (Article _ (Full bod)) =
|
||||||
|
bod
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is the only way you can transform an existing article:
|
||||||
|
you can change its author (e.g. to follow or unfollow them).
|
||||||
|
All other article data necessarily comes from the server!
|
||||||
|
|
||||||
|
We can tell this for sure by looking at the types of the exposed functions
|
||||||
|
in this module.
|
||||||
|
|
||||||
|
-}
|
||||||
|
mapAuthor : (Author -> Author) -> Article a -> Article a
|
||||||
|
mapAuthor transform (Article info extras) =
|
||||||
|
Article { info | author = transform info.author } extras
|
||||||
|
|
||||||
|
|
||||||
|
fromPreview : Body -> Article Preview -> Article Full
|
||||||
|
fromPreview bod (Article info _) =
|
||||||
|
Article info (Full bod)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
previewDecoder : Maybe Cred -> Decoder (Article Preview)
|
||||||
|
previewDecoder maybeCred =
|
||||||
|
Decode.succeed Article
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> hardcoded Preview
|
||||||
|
|
||||||
|
|
||||||
|
fullDecoder : Maybe Cred -> Decoder (Article Full)
|
||||||
|
fullDecoder maybeCred =
|
||||||
|
Decode.succeed Article
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> required "body" (Decode.map Full Body.decoder)
|
||||||
|
|
||||||
|
|
||||||
|
internalsDecoder : Maybe Cred -> Decoder Internals
|
||||||
|
internalsDecoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "slug" Slug.decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> custom metadataDecoder
|
||||||
|
|
||||||
|
|
||||||
|
metadataDecoder : Decoder Metadata
|
||||||
|
metadataDecoder =
|
||||||
|
Decode.succeed Metadata
|
||||||
|
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|
||||||
|
|> required "title" Decode.string
|
||||||
|
|> required "tagList" (Decode.list Decode.string)
|
||||||
|
|> required "createdAt" Timestamp.iso8601Decoder
|
||||||
|
|> required "favorited" Decode.bool
|
||||||
|
|> required "favoritesCount" Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SINGLE
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Maybe Cred -> Slug -> Http.Request (Article Full)
|
||||||
|
fetch maybeCred articleSlug =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
fullDecoder maybeCred
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
url articleSlug []
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect expect
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FAVORITE
|
||||||
|
|
||||||
|
|
||||||
|
favorite : Slug -> Cred -> Http.Request (Article Preview)
|
||||||
|
favorite articleSlug cred =
|
||||||
|
buildFavorite HttpBuilder.post articleSlug cred
|
||||||
|
|
||||||
|
|
||||||
|
unfavorite : Slug -> Cred -> Http.Request (Article Preview)
|
||||||
|
unfavorite articleSlug cred =
|
||||||
|
buildFavorite HttpBuilder.delete articleSlug cred
|
||||||
|
|
||||||
|
|
||||||
|
buildFavorite :
|
||||||
|
(String -> RequestBuilder a)
|
||||||
|
-> Slug
|
||||||
|
-> Cred
|
||||||
|
-> Http.Request (Article Preview)
|
||||||
|
buildFavorite builderFromUrl articleSlug cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
previewDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
builderFromUrl (url articleSlug [ "favorite" ])
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is a "build your own element" API.
|
||||||
|
|
||||||
|
You pass it some configuration, followed by a `List (Attribute msg)` and a
|
||||||
|
`List (Html msg)`, just like any standard Html element.
|
||||||
|
|
||||||
|
-}
|
||||||
|
favoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
|
||||||
|
favoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
unfavoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
|
||||||
|
unfavoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
toggleFavoriteButton :
|
||||||
|
String
|
||||||
|
-> msg
|
||||||
|
-> List (Attribute msg)
|
||||||
|
-> List (Html msg)
|
||||||
|
-> Html msg
|
||||||
|
toggleFavoriteButton classStr msg attrs kids =
|
||||||
|
Html.button
|
||||||
|
(class classStr :: onClickStopPropagation msg :: attrs)
|
||||||
|
(i [ class "ion-heart" ] [] :: kids)
|
||||||
|
|
||||||
|
|
||||||
|
onClickStopPropagation : msg -> Attribute msg
|
||||||
|
onClickStopPropagation msg =
|
||||||
|
stopPropagationOn "click"
|
||||||
|
(Decode.succeed ( msg, True ))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URLS
|
||||||
|
|
||||||
|
|
||||||
|
url : Slug -> List String -> String
|
||||||
|
url articleSlug paths =
|
||||||
|
allArticlesUrl (Slug.toString articleSlug :: paths)
|
||||||
|
|
||||||
|
|
||||||
|
allArticlesUrl : List String -> String
|
||||||
|
allArticlesUrl paths =
|
||||||
|
Api.url ("articles" :: paths)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
module Article.Body exposing (Body, MarkdownString, decoder, toHtml, toMarkdownString)
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Markdown
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Body
|
||||||
|
= Body MarkdownString
|
||||||
|
|
||||||
|
|
||||||
|
{-| Internal use only. I want to remind myself that the string inside Body contains markdown.
|
||||||
|
-}
|
||||||
|
type alias MarkdownString =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CONVERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
toHtml : Body -> List (Attribute msg) -> Html msg
|
||||||
|
toHtml (Body markdown) attributes =
|
||||||
|
Markdown.toHtml attributes markdown
|
||||||
|
|
||||||
|
|
||||||
|
toMarkdownString : Body -> MarkdownString
|
||||||
|
toMarkdownString (Body markdown) =
|
||||||
|
markdown
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Body
|
||||||
|
decoder =
|
||||||
|
Decode.map Body Decode.string
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
module Article.Comment
|
||||||
|
exposing
|
||||||
|
( Comment
|
||||||
|
, author
|
||||||
|
, body
|
||||||
|
, createdAt
|
||||||
|
, delete
|
||||||
|
, id
|
||||||
|
, list
|
||||||
|
, post
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Author exposing (Author)
|
||||||
|
import CommentId exposing (CommentId)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Comment
|
||||||
|
= Comment Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ id : CommentId
|
||||||
|
, body : String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, author : Author
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
id : Comment -> CommentId
|
||||||
|
id (Comment comment) =
|
||||||
|
comment.id
|
||||||
|
|
||||||
|
|
||||||
|
body : Comment -> String
|
||||||
|
body (Comment comment) =
|
||||||
|
comment.body
|
||||||
|
|
||||||
|
|
||||||
|
createdAt : Comment -> Time.Posix
|
||||||
|
createdAt (Comment comment) =
|
||||||
|
comment.createdAt
|
||||||
|
|
||||||
|
|
||||||
|
author : Comment -> Author
|
||||||
|
author (Comment comment) =
|
||||||
|
comment.author
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LIST
|
||||||
|
|
||||||
|
|
||||||
|
list : Maybe Cred -> Slug -> Http.Request (List Comment)
|
||||||
|
list maybeCred articleSlug =
|
||||||
|
allCommentsUrl articleSlug []
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "comments" (Decode.list (decoder maybeCred))))
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- POST
|
||||||
|
|
||||||
|
|
||||||
|
post : Slug -> String -> Cred -> Http.Request Comment
|
||||||
|
post articleSlug commentBody cred =
|
||||||
|
allCommentsUrl articleSlug []
|
||||||
|
|> HttpBuilder.post
|
||||||
|
|> HttpBuilder.withBody (Http.jsonBody (encodeCommentBody commentBody))
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "comment" (decoder (Just cred))))
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
encodeCommentBody : String -> Value
|
||||||
|
encodeCommentBody str =
|
||||||
|
Encode.object [ ( "comment", Encode.object [ ( "body", Encode.string str ) ] ) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- DELETE
|
||||||
|
|
||||||
|
|
||||||
|
delete : Slug -> CommentId -> Cred -> Http.Request ()
|
||||||
|
delete articleSlug commentId cred =
|
||||||
|
commentUrl articleSlug commentId
|
||||||
|
|> HttpBuilder.delete
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Decoder Comment
|
||||||
|
decoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "id" CommentId.decoder
|
||||||
|
|> required "body" Decode.string
|
||||||
|
|> required "createdAt" Timestamp.iso8601Decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> Decode.map Comment
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URLS
|
||||||
|
|
||||||
|
|
||||||
|
commentUrl : Slug -> CommentId -> String
|
||||||
|
commentUrl articleSlug commentId =
|
||||||
|
allCommentsUrl articleSlug [ CommentId.toString commentId ]
|
||||||
|
|
||||||
|
|
||||||
|
allCommentsUrl : Slug -> List String -> String
|
||||||
|
allCommentsUrl articleSlug paths =
|
||||||
|
Api.url ([ "articles", Slug.toString articleSlug, "comments" ] ++ paths)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
module Article.Feed
|
||||||
|
exposing
|
||||||
|
( Model
|
||||||
|
, Msg
|
||||||
|
, decoder
|
||||||
|
, init
|
||||||
|
, update
|
||||||
|
, viewArticles
|
||||||
|
, viewPagination
|
||||||
|
, viewTabs
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Article.Slug as ArticleSlug exposing (Slug)
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Author
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Profile
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| NOTE: This module has its own Model, view, and update. This is not normal!
|
||||||
|
If you find yourself doing this often, please watch <https://www.youtube.com/watch?v=DoA4Txr4GUs>
|
||||||
|
|
||||||
|
This is the reusable Article Feed that appears on both the Home page as well as
|
||||||
|
on the Profile page. There's a lot of logic here, so it's more convenient to use
|
||||||
|
the heavyweight approach of giving this its own Model, view, and update.
|
||||||
|
|
||||||
|
This means callers must use Html.map and Cmd.map to use this thing, but in
|
||||||
|
this case that's totally worth it because of the amount of logic wrapped up
|
||||||
|
in this thing.
|
||||||
|
|
||||||
|
For every other reusable view in this application, this API would be totally
|
||||||
|
overkill, so we use simpler APIs instead.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Model Internals
|
||||||
|
|
||||||
|
|
||||||
|
{-| This should not be exposed! We want to benefit from the guarantee that only
|
||||||
|
this module can create or alter this model. This way if it ever ends up in
|
||||||
|
a surprising state, we know exactly where to look: this module.
|
||||||
|
-}
|
||||||
|
type alias Internals =
|
||||||
|
{ session : Session
|
||||||
|
, errors : List String
|
||||||
|
, articles : PaginatedList (Article Preview)
|
||||||
|
, isLoading : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> PaginatedList (Article Preview) -> Model
|
||||||
|
init session articles =
|
||||||
|
Model
|
||||||
|
{ session = session
|
||||||
|
, errors = []
|
||||||
|
, articles = articles
|
||||||
|
, isLoading = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
viewArticles : Time.Zone -> Model -> List (Html Msg)
|
||||||
|
viewArticles timeZone (Model { articles, session, errors }) =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
articlesHtml =
|
||||||
|
PaginatedList.values articles
|
||||||
|
|> List.map (viewPreview maybeCred timeZone)
|
||||||
|
in
|
||||||
|
Page.viewErrors ClickedDismissErrors errors :: articlesHtml
|
||||||
|
|
||||||
|
|
||||||
|
viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg
|
||||||
|
viewPreview maybeCred timeZone article =
|
||||||
|
let
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
{ title, description, createdAt } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
author =
|
||||||
|
Article.author article
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
username =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
faveButton =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
let
|
||||||
|
{ favoritesCount, favorited } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
viewButton =
|
||||||
|
if favorited then
|
||||||
|
Article.unfavoriteButton cred (ClickedUnfavorite cred slug)
|
||||||
|
|
||||||
|
else
|
||||||
|
Article.favoriteButton cred (ClickedFavorite cred slug)
|
||||||
|
in
|
||||||
|
viewButton [ class "pull-xs-right" ]
|
||||||
|
[ text (" " ++ String.fromInt favoritesCount) ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text ""
|
||||||
|
in
|
||||||
|
div [ class "article-preview" ]
|
||||||
|
[ div [ class "article-meta" ]
|
||||||
|
[ a [ Route.href (Route.Profile username) ]
|
||||||
|
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view username
|
||||||
|
, Timestamp.view timeZone createdAt
|
||||||
|
]
|
||||||
|
, faveButton
|
||||||
|
]
|
||||||
|
, a [ class "preview-link", Route.href (Route.Article (Article.slug article)) ]
|
||||||
|
[ h1 [] [ text title ]
|
||||||
|
, p [] [ text description ]
|
||||||
|
, span [] [ text "Read more..." ]
|
||||||
|
, ul [ class "tag-list" ]
|
||||||
|
(List.map viewTag (Article.metadata article).tags)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs :
|
||||||
|
List ( String, msg )
|
||||||
|
-> ( String, msg )
|
||||||
|
-> List ( String, msg )
|
||||||
|
-> Html msg
|
||||||
|
viewTabs before selected after =
|
||||||
|
ul [ class "nav nav-pills outline-active" ] <|
|
||||||
|
List.concat
|
||||||
|
[ List.map (viewTab []) before
|
||||||
|
, [ viewTab [ class "active" ] selected ]
|
||||||
|
, List.map (viewTab []) after
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTab : List (Attribute msg) -> ( String, msg ) -> Html msg
|
||||||
|
viewTab attrs ( name, msg ) =
|
||||||
|
li [ class "nav-item" ]
|
||||||
|
[ -- Note: The RealWorld CSS requires an href to work properly.
|
||||||
|
a (class "nav-link" :: onClick msg :: href "" :: attrs)
|
||||||
|
[ text name ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewPagination : (Int -> msg) -> Model -> Html msg
|
||||||
|
viewPagination toMsg (Model feed) =
|
||||||
|
PaginatedList.view toMsg feed.articles
|
||||||
|
|
||||||
|
|
||||||
|
viewTag : String -> Html msg
|
||||||
|
viewTag tagName =
|
||||||
|
li [ class "tag-default tag-pill tag-outline" ] [ text tagName ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDismissErrors
|
||||||
|
| ClickedFavorite Cred Slug
|
||||||
|
| ClickedUnfavorite Cred Slug
|
||||||
|
| CompletedFavorite (Result Http.Error (Article Preview))
|
||||||
|
|
||||||
|
|
||||||
|
update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update maybeCred msg (Model model) =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( Model { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedFavorite cred slug ->
|
||||||
|
fave Article.favorite cred slug model
|
||||||
|
|
||||||
|
ClickedUnfavorite cred slug ->
|
||||||
|
fave Article.unfavorite cred slug model
|
||||||
|
|
||||||
|
CompletedFavorite (Ok article) ->
|
||||||
|
( Model { model | articles = PaginatedList.map (replaceArticle article) model.articles }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFavorite (Err error) ->
|
||||||
|
( Model { model | errors = Api.addServerError model.errors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
replaceArticle : Article a -> Article a -> Article a
|
||||||
|
replaceArticle newArticle oldArticle =
|
||||||
|
if Article.slug newArticle == Article.slug oldArticle then
|
||||||
|
newArticle
|
||||||
|
|
||||||
|
else
|
||||||
|
oldArticle
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Int -> Decoder (PaginatedList (Article Preview))
|
||||||
|
decoder maybeCred resultsPerPage =
|
||||||
|
Decode.succeed PaginatedList.fromList
|
||||||
|
|> required "articlesCount" (pageCountDecoder resultsPerPage)
|
||||||
|
|> required "articles" (Decode.list (Article.previewDecoder maybeCred))
|
||||||
|
|
||||||
|
|
||||||
|
pageCountDecoder : Int -> Decoder Int
|
||||||
|
pageCountDecoder resultsPerPage =
|
||||||
|
Decode.int
|
||||||
|
|> Decode.map (\total -> ceiling (toFloat total / toFloat resultsPerPage))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Internals -> ( Model, Cmd Msg )
|
||||||
|
fave toRequest cred slug model =
|
||||||
|
( Model model
|
||||||
|
, toRequest slug cred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.attempt CompletedFavorite
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
module Article.FeedSources exposing (FeedSources, Source(..), after, before, fromLists, select, selected)
|
||||||
|
|
||||||
|
import Article
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type FeedSources
|
||||||
|
= FeedSources
|
||||||
|
{ before : List Source
|
||||||
|
, selected : Source
|
||||||
|
, after : List Source
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Source
|
||||||
|
= YourFeed Cred
|
||||||
|
| GlobalFeed
|
||||||
|
| TagFeed Tag
|
||||||
|
| FavoritedFeed Username
|
||||||
|
| AuthorFeed Username
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- BUILDING
|
||||||
|
|
||||||
|
|
||||||
|
fromLists : Source -> List Source -> FeedSources
|
||||||
|
fromLists selectedSource afterSources =
|
||||||
|
FeedSources
|
||||||
|
{ before = []
|
||||||
|
, selected = selectedSource
|
||||||
|
, after = afterSources
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SELECTING
|
||||||
|
|
||||||
|
|
||||||
|
select : Source -> FeedSources -> FeedSources
|
||||||
|
select selectedSource (FeedSources sources) =
|
||||||
|
let
|
||||||
|
( newBefore, newAfter ) =
|
||||||
|
(sources.before ++ (sources.selected :: sources.after))
|
||||||
|
-- By design, tags can only be included if they're selected.
|
||||||
|
|> List.filter isNotTag
|
||||||
|
|> splitOn (\source -> source == selectedSource)
|
||||||
|
in
|
||||||
|
FeedSources
|
||||||
|
{ before = List.reverse newBefore
|
||||||
|
, selected = selectedSource
|
||||||
|
, after = List.reverse newAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
splitOn : (Source -> Bool) -> List Source -> ( List Source, List Source )
|
||||||
|
splitOn isSelected sources =
|
||||||
|
let
|
||||||
|
( _, newBefore, newAfter ) =
|
||||||
|
List.foldl (splitOnHelp isSelected) ( False, [], [] ) sources
|
||||||
|
in
|
||||||
|
( newBefore, newAfter )
|
||||||
|
|
||||||
|
|
||||||
|
splitOnHelp : (Source -> Bool) -> Source -> ( Bool, List Source, List Source ) -> ( Bool, List Source, List Source )
|
||||||
|
splitOnHelp isSelected source ( foundSelected, beforeSelected, afterSelected ) =
|
||||||
|
if isSelected source then
|
||||||
|
( True, beforeSelected, afterSelected )
|
||||||
|
|
||||||
|
else if foundSelected then
|
||||||
|
( foundSelected, beforeSelected, source :: afterSelected )
|
||||||
|
|
||||||
|
else
|
||||||
|
( foundSelected, source :: beforeSelected, afterSelected )
|
||||||
|
|
||||||
|
|
||||||
|
isNotTag : Source -> Bool
|
||||||
|
isNotTag currentSource =
|
||||||
|
case currentSource of
|
||||||
|
TagFeed _ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
selected : FeedSources -> Source
|
||||||
|
selected (FeedSources record) =
|
||||||
|
record.selected
|
||||||
|
|
||||||
|
|
||||||
|
before : FeedSources -> List Source
|
||||||
|
before (FeedSources record) =
|
||||||
|
record.before
|
||||||
|
|
||||||
|
|
||||||
|
after : FeedSources -> List Source
|
||||||
|
after (FeedSources record) =
|
||||||
|
record.after
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
module Article.Slug exposing (Slug, decoder, toString, urlParser)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Url.Parser exposing (Parser)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Slug
|
||||||
|
= Slug String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Parser (Slug -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "SLUG" (\str -> Just (Slug str))
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Slug
|
||||||
|
decoder =
|
||||||
|
Decode.map Slug Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : Slug -> String
|
||||||
|
toString (Slug str) =
|
||||||
|
str
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
module Article.Tag exposing (Tag, list, toString, validate)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Tag
|
||||||
|
= Tag String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : Tag -> String
|
||||||
|
toString (Tag slug) =
|
||||||
|
slug
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LIST
|
||||||
|
|
||||||
|
|
||||||
|
list : Http.Request (List Tag)
|
||||||
|
list =
|
||||||
|
Decode.field "tags" (Decode.list decoder)
|
||||||
|
|> Http.get (Api.url [ "tags" ])
|
||||||
|
|
||||||
|
|
||||||
|
validate : String -> List String -> Bool
|
||||||
|
validate str =
|
||||||
|
String.split " " str
|
||||||
|
|> List.map String.trim
|
||||||
|
|> List.filter (not << String.isEmpty)
|
||||||
|
|> (==)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Tag
|
||||||
|
decoder =
|
||||||
|
Decode.map Tag Decode.string
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module Asset exposing (Image, defaultAvatar, error, loading, src)
|
||||||
|
|
||||||
|
{-| Assets, such as images, videos, and audio. (We only have images for now.)
|
||||||
|
|
||||||
|
We should never expose asset URLs directly; this module should be in charge of
|
||||||
|
all of them. One source of truth!
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
|
||||||
|
|
||||||
|
type Image
|
||||||
|
= Image String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
error : Image
|
||||||
|
error =
|
||||||
|
image "error.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
loading : Image
|
||||||
|
loading =
|
||||||
|
image "loading.svg"
|
||||||
|
|
||||||
|
|
||||||
|
defaultAvatar : Image
|
||||||
|
defaultAvatar =
|
||||||
|
image "smiley-cyrus.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
image : String -> Image
|
||||||
|
image filename =
|
||||||
|
Image ("/assets/images/" ++ filename)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- USING IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
src : Image -> Attribute msg
|
||||||
|
src (Image url) =
|
||||||
|
Attr.src url
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
module Author
|
||||||
|
exposing
|
||||||
|
( Author(..)
|
||||||
|
, FollowedAuthor
|
||||||
|
, UnfollowedAuthor
|
||||||
|
, decoder
|
||||||
|
, fetch
|
||||||
|
, follow
|
||||||
|
, followButton
|
||||||
|
, profile
|
||||||
|
, requestFollow
|
||||||
|
, requestUnfollow
|
||||||
|
, unfollow
|
||||||
|
, unfollowButton
|
||||||
|
, username
|
||||||
|
, view
|
||||||
|
)
|
||||||
|
|
||||||
|
{-| The author of an Article. It includes a Profile.
|
||||||
|
|
||||||
|
I designed this to make sure the compiler would help me keep these three
|
||||||
|
possibilities straight when displaying follow buttons and such:
|
||||||
|
|
||||||
|
- I'm following this author.
|
||||||
|
- I'm not following this author.
|
||||||
|
- I _can't_ follow this author, because it's me!
|
||||||
|
|
||||||
|
To do this, I defined `Author` a custom type with three variants, one for each
|
||||||
|
of those possibilities.
|
||||||
|
|
||||||
|
I also made separate types for FollowedAuthor and UnfollowedAuthor.
|
||||||
|
They are custom type wrappers around Profile, and thier sole purpose is to
|
||||||
|
help me keep track of which operations are supported.
|
||||||
|
|
||||||
|
For example, consider these functions:
|
||||||
|
|
||||||
|
requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
|
||||||
|
These types help the compiler prevent several mistakes:
|
||||||
|
|
||||||
|
- Displaying a Follow button for an author the user already follows.
|
||||||
|
- Displaying an Unfollow button for an author the user already doesn't follow.
|
||||||
|
- Displaying either button when the author is ourself.
|
||||||
|
|
||||||
|
There are still ways we could mess things up (e.g. make a button that calls Author.unfollow when you click it, but which displays "Follow" to the user) - but this rules out a bunch of potential problems.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Html exposing (Html, a, i, text)
|
||||||
|
import Html.Attributes exposing (attribute, class, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, optional, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author - either the current user, another user we're following, or
|
||||||
|
another user we aren't following.
|
||||||
|
|
||||||
|
These distinctions matter because we can only perform "follow" requests for
|
||||||
|
users we aren't following, we can only perform "unfollow" requests for
|
||||||
|
users we _are_ following, and we can't perform either for ourselves.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Author
|
||||||
|
= IsFollowing FollowedAuthor
|
||||||
|
| IsNotFollowing UnfollowedAuthor
|
||||||
|
| IsViewer Cred Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author we're following.
|
||||||
|
-}
|
||||||
|
type FollowedAuthor
|
||||||
|
= FollowedAuthor Username Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author we're not following.
|
||||||
|
-}
|
||||||
|
type UnfollowedAuthor
|
||||||
|
= UnfollowedAuthor Username Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| Return an Author's username.
|
||||||
|
-}
|
||||||
|
username : Author -> Username
|
||||||
|
username author =
|
||||||
|
case author of
|
||||||
|
IsViewer cred _ ->
|
||||||
|
Cred.username cred
|
||||||
|
|
||||||
|
IsFollowing (FollowedAuthor val _) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsNotFollowing (UnfollowedAuthor val _) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
{-| Return an Author's profile.
|
||||||
|
-}
|
||||||
|
profile : Author -> Profile
|
||||||
|
profile author =
|
||||||
|
case author of
|
||||||
|
IsViewer _ val ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsFollowing (FollowedAuthor _ val) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsNotFollowing (UnfollowedAuthor _ val) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FETCH
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Username -> Maybe Cred -> Http.Request Author
|
||||||
|
fetch uname maybeCred =
|
||||||
|
Api.url [ "profiles", Username.toString uname ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "profile" (decoder maybeCred)))
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FOLLOWING
|
||||||
|
|
||||||
|
|
||||||
|
follow : UnfollowedAuthor -> FollowedAuthor
|
||||||
|
follow (UnfollowedAuthor uname prof) =
|
||||||
|
FollowedAuthor uname prof
|
||||||
|
|
||||||
|
|
||||||
|
unfollow : FollowedAuthor -> UnfollowedAuthor
|
||||||
|
unfollow (FollowedAuthor uname prof) =
|
||||||
|
UnfollowedAuthor uname prof
|
||||||
|
|
||||||
|
|
||||||
|
requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestFollow (UnfollowedAuthor uname _) cred =
|
||||||
|
requestHelp HttpBuilder.post uname cred
|
||||||
|
|
||||||
|
|
||||||
|
requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestUnfollow (FollowedAuthor uname _) cred =
|
||||||
|
requestHelp HttpBuilder.delete uname cred
|
||||||
|
|
||||||
|
|
||||||
|
requestHelp :
|
||||||
|
(String -> RequestBuilder a)
|
||||||
|
-> Username
|
||||||
|
-> Cred
|
||||||
|
-> Http.Request Author
|
||||||
|
requestHelp builderFromUrl uname cred =
|
||||||
|
Api.url [ "profiles", Username.toString uname, "follow" ]
|
||||||
|
|> builderFromUrl
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withExpect (Http.expectJson (Decode.field "profile" (decoder Nothing)))
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
followButton : (Cred -> UnfollowedAuthor -> msg) -> Cred -> UnfollowedAuthor -> Html msg
|
||||||
|
followButton toMsg cred ((UnfollowedAuthor uname _) as author) =
|
||||||
|
toggleFollowButton "Follow"
|
||||||
|
[ "btn-outline-secondary" ]
|
||||||
|
(toMsg cred author)
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
unfollowButton : (Cred -> FollowedAuthor -> msg) -> Cred -> FollowedAuthor -> Html msg
|
||||||
|
unfollowButton toMsg cred ((FollowedAuthor uname _) as author) =
|
||||||
|
toggleFollowButton "Unfollow"
|
||||||
|
[ "btn-secondary" ]
|
||||||
|
(toMsg cred author)
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
toggleFollowButton : String -> List String -> msg -> Username -> Html msg
|
||||||
|
toggleFollowButton txt extraClasses msgWhenClicked uname =
|
||||||
|
let
|
||||||
|
classStr =
|
||||||
|
"btn btn-sm " ++ String.join " " extraClasses ++ " action-btn"
|
||||||
|
|
||||||
|
caption =
|
||||||
|
" " ++ txt ++ " " ++ Username.toString uname
|
||||||
|
in
|
||||||
|
Html.button [ class classStr, onClick msgWhenClicked ]
|
||||||
|
[ i [ class "ion-plus-round" ] []
|
||||||
|
, text caption
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Decoder Author
|
||||||
|
decoder maybeCred =
|
||||||
|
Decode.succeed Tuple.pair
|
||||||
|
|> custom Profile.decoder
|
||||||
|
|> required "username" Username.decoder
|
||||||
|
|> Decode.andThen (decodeFromPair maybeCred)
|
||||||
|
|
||||||
|
|
||||||
|
decodeFromPair : Maybe Cred -> ( Profile, Username ) -> Decoder Author
|
||||||
|
decodeFromPair maybeCred ( prof, uname ) =
|
||||||
|
case maybeCred of
|
||||||
|
Nothing ->
|
||||||
|
-- If you're logged out, you can't be following anyone!
|
||||||
|
Decode.succeed (IsNotFollowing (UnfollowedAuthor uname prof))
|
||||||
|
|
||||||
|
Just cred ->
|
||||||
|
if uname == Cred.username cred then
|
||||||
|
Decode.succeed (IsViewer cred prof)
|
||||||
|
|
||||||
|
else
|
||||||
|
nonViewerDecoder prof uname
|
||||||
|
|
||||||
|
|
||||||
|
nonViewerDecoder : Profile -> Username -> Decoder Author
|
||||||
|
nonViewerDecoder prof uname =
|
||||||
|
Decode.succeed (authorFromFollowing prof uname)
|
||||||
|
|> optional "following" Decode.bool False
|
||||||
|
|
||||||
|
|
||||||
|
authorFromFollowing : Profile -> Username -> Bool -> Author
|
||||||
|
authorFromFollowing prof uname isFollowing =
|
||||||
|
if isFollowing then
|
||||||
|
IsFollowing (FollowedAuthor uname prof)
|
||||||
|
|
||||||
|
else
|
||||||
|
IsNotFollowing (UnfollowedAuthor uname prof)
|
||||||
|
|
||||||
|
|
||||||
|
{-| View an author. We basically render their username and a link to their
|
||||||
|
profile, and that's it.
|
||||||
|
-}
|
||||||
|
view : Username -> Html msg
|
||||||
|
view uname =
|
||||||
|
a [ class "author", Route.href (Route.Profile uname) ]
|
||||||
|
[ Username.toHtml uname ]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Avatar
|
||||||
|
= Avatar (Maybe String)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Avatar
|
||||||
|
decoder =
|
||||||
|
Decode.map Avatar (Decode.nullable Decode.string)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Avatar -> Value
|
||||||
|
encode (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Just url ->
|
||||||
|
Encode.string url
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Encode.null
|
||||||
|
|
||||||
|
|
||||||
|
src : Avatar -> Attribute msg
|
||||||
|
src (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Nothing ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just "" ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just url ->
|
||||||
|
Html.Attributes.src url
|
||||||
|
|
||||||
|
|
||||||
|
toMaybeString : Avatar -> Maybe String
|
||||||
|
toMaybeString (Avatar maybeUrl) =
|
||||||
|
maybeUrl
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module CommentId exposing (CommentId, decoder, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type CommentId
|
||||||
|
= CommentId Int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder CommentId
|
||||||
|
decoder =
|
||||||
|
Decode.map CommentId Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : CommentId -> String
|
||||||
|
toString (CommentId id) =
|
||||||
|
String.fromInt id
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
module Email exposing (Email, decoder, encode, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An email address.
|
||||||
|
|
||||||
|
Having this as a custom type that's separate from String makes certain
|
||||||
|
mistakes impossible. Consider this function:
|
||||||
|
|
||||||
|
updateEmailAddress : Email -> String -> Http.Request
|
||||||
|
updateEmailAddress email password = ...
|
||||||
|
|
||||||
|
(The server needs your password to confirm that you should be allowed
|
||||||
|
to update the email address.)
|
||||||
|
|
||||||
|
Because Email is not a type alias for String, but is instead a separate
|
||||||
|
custom type, it is now impossible to mix up the argument order of the
|
||||||
|
email and the password. If we do, it won't compile!
|
||||||
|
|
||||||
|
If Email were instead defined as `type alias Email = String`, we could
|
||||||
|
call updateEmailAddress password email and it would compile (and never
|
||||||
|
work properly).
|
||||||
|
|
||||||
|
This way, we make it impossible for a bug like that to compile!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Email
|
||||||
|
= Email String
|
||||||
|
|
||||||
|
|
||||||
|
toString : Email -> String
|
||||||
|
toString (Email str) =
|
||||||
|
str
|
||||||
|
|
||||||
|
|
||||||
|
encode : Email -> Value
|
||||||
|
encode (Email str) =
|
||||||
|
Encode.string str
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Email
|
||||||
|
decoder =
|
||||||
|
Decode.map Email Decode.string
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module Loading exposing (error, icon, slowThreshold)
|
||||||
|
|
||||||
|
{-| A loading spinner icon.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes exposing (alt, height, src, width)
|
||||||
|
import Process
|
||||||
|
import Task exposing (Task)
|
||||||
|
|
||||||
|
|
||||||
|
icon : Html msg
|
||||||
|
icon =
|
||||||
|
Html.img [ Asset.src Asset.loading, width 64, height 64, alt "Loading..." ] []
|
||||||
|
|
||||||
|
|
||||||
|
error : String -> Html msg
|
||||||
|
error str =
|
||||||
|
Html.text ("Error loading " ++ str ++ ".")
|
||||||
|
|
||||||
|
|
||||||
|
slowThreshold : Task x ()
|
||||||
|
slowThreshold =
|
||||||
|
Process.sleep 500
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module Log exposing (error)
|
||||||
|
|
||||||
|
{-| This is a placeholder API for how we might do logging through
|
||||||
|
some service like <http://rollbar.com> (which is what we use at work).
|
||||||
|
|
||||||
|
Whenever you see Log.error used in this code base, it means
|
||||||
|
"Something unexpected happened. This is where we would log an
|
||||||
|
error to our server with some diagnostic info so we could investigate
|
||||||
|
what happened later."
|
||||||
|
|
||||||
|
(Since this is outside the scope of the RealWorld spec, and is only
|
||||||
|
a placeholder anyway, I didn't bother making this function accept actual
|
||||||
|
diagnostic info, authentication tokens, etc.)
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
error : Cmd msg
|
||||||
|
error =
|
||||||
|
Cmd.none
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Article.FeedSources as FeedSources
|
||||||
|
import Article.Slug exposing (Slug)
|
||||||
|
import Browser exposing (Document)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Json.Decode as Decode exposing (Value)
|
||||||
|
import Page exposing (Page)
|
||||||
|
import Page.Article as Article
|
||||||
|
import Page.Article.Editor as Editor
|
||||||
|
import Page.Blank as Blank
|
||||||
|
import Page.Home as Home
|
||||||
|
import Page.Login as Login
|
||||||
|
import Page.NotFound as NotFound
|
||||||
|
import Page.Profile as Profile
|
||||||
|
import Page.Register as Register
|
||||||
|
import Page.Settings as Settings
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task
|
||||||
|
import Time
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- WARNING: Based on discussions around how asset management features
|
||||||
|
-- like code splitting and lazy loading have been shaping up, I expect
|
||||||
|
-- most of this file to become unnecessary in a future release of Elm.
|
||||||
|
-- Avoid putting things in here unless there is no alternative!
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Redirect Session
|
||||||
|
| NotFound Session
|
||||||
|
| Home Home.Model
|
||||||
|
| Settings Settings.Model
|
||||||
|
| Login Login.Model
|
||||||
|
| Register Register.Model
|
||||||
|
| Profile Username Profile.Model
|
||||||
|
| Article Article.Model
|
||||||
|
| Editor (Maybe Slug) Editor.Model
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
|
init flags url navKey =
|
||||||
|
changeRouteTo (Route.fromUrl url)
|
||||||
|
(Redirect (Session.decode navKey flags))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Document Msg
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
viewPage page toMsg config =
|
||||||
|
let
|
||||||
|
{ title, body } =
|
||||||
|
Page.view (Session.viewer (toSession model)) page config
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, body = List.map (Html.map toMsg) body
|
||||||
|
}
|
||||||
|
in
|
||||||
|
case model of
|
||||||
|
Redirect _ ->
|
||||||
|
viewPage Page.Other (\_ -> Ignored) Blank.view
|
||||||
|
|
||||||
|
NotFound _ ->
|
||||||
|
viewPage Page.Other (\_ -> Ignored) NotFound.view
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
viewPage Page.Other GotSettingsMsg (Settings.view settings)
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
viewPage Page.Home GotHomeMsg (Home.view home)
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
viewPage Page.Other GotLoginMsg (Login.view login)
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
viewPage Page.Other GotRegisterMsg (Register.view register)
|
||||||
|
|
||||||
|
Profile username profile ->
|
||||||
|
viewPage (Page.Profile username) GotProfileMsg (Profile.view profile)
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
viewPage Page.Other GotArticleMsg (Article.view article)
|
||||||
|
|
||||||
|
Editor Nothing editor ->
|
||||||
|
viewPage Page.NewArticle GotEditorMsg (Editor.view editor)
|
||||||
|
|
||||||
|
Editor (Just _) editor ->
|
||||||
|
viewPage Page.Other GotEditorMsg (Editor.view editor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Ignored
|
||||||
|
| ChangedRoute (Maybe Route)
|
||||||
|
| ChangedUrl Url
|
||||||
|
| ClickedLink Browser.UrlRequest
|
||||||
|
| GotHomeMsg Home.Msg
|
||||||
|
| GotSettingsMsg Settings.Msg
|
||||||
|
| GotLoginMsg Login.Msg
|
||||||
|
| GotRegisterMsg Register.Msg
|
||||||
|
| GotProfileMsg Profile.Msg
|
||||||
|
| GotArticleMsg Article.Msg
|
||||||
|
| GotEditorMsg Editor.Msg
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession page =
|
||||||
|
case page of
|
||||||
|
Redirect session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
NotFound session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Home.toSession home
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
Settings.toSession settings
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Login.toSession login
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
Register.toSession register
|
||||||
|
|
||||||
|
Profile _ profile ->
|
||||||
|
Profile.toSession profile
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
Article.toSession article
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Editor.toSession editor
|
||||||
|
|
||||||
|
|
||||||
|
changeRouteTo : Maybe Route -> Model -> ( Model, Cmd Msg )
|
||||||
|
changeRouteTo maybeRoute model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
toSession model
|
||||||
|
in
|
||||||
|
case maybeRoute of
|
||||||
|
Nothing ->
|
||||||
|
( NotFound session, Cmd.none )
|
||||||
|
|
||||||
|
Just Route.Root ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey session) Route.Home )
|
||||||
|
|
||||||
|
Just Route.Logout ->
|
||||||
|
( model, Session.logout )
|
||||||
|
|
||||||
|
Just Route.NewArticle ->
|
||||||
|
Editor.initNew session
|
||||||
|
|> updateWith (Editor Nothing) GotEditorMsg model
|
||||||
|
|
||||||
|
Just (Route.EditArticle slug) ->
|
||||||
|
Editor.initEdit session slug
|
||||||
|
|> updateWith (Editor (Just slug)) GotEditorMsg model
|
||||||
|
|
||||||
|
Just Route.Settings ->
|
||||||
|
Settings.init session
|
||||||
|
|> updateWith Settings GotSettingsMsg model
|
||||||
|
|
||||||
|
Just Route.Home ->
|
||||||
|
Home.init session
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
Just Route.Login ->
|
||||||
|
Login.init session
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
Just Route.Register ->
|
||||||
|
Register.init session
|
||||||
|
|> updateWith Register GotRegisterMsg model
|
||||||
|
|
||||||
|
Just (Route.Profile username) ->
|
||||||
|
Profile.init session username
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
Just (Route.Article slug) ->
|
||||||
|
Article.init session slug
|
||||||
|
|> updateWith Article GotArticleMsg model
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case ( msg, model ) of
|
||||||
|
( Ignored, _ ) ->
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
( ClickedLink urlRequest, _ ) ->
|
||||||
|
case urlRequest of
|
||||||
|
Browser.Internal url ->
|
||||||
|
case url.fragment of
|
||||||
|
Nothing ->
|
||||||
|
-- If we got a link that didn't include a fragment,
|
||||||
|
-- it's from one of those (href "") attributes that
|
||||||
|
-- we have to include to make the RealWorld CSS work.
|
||||||
|
--
|
||||||
|
-- In an application doing path routing instead of
|
||||||
|
-- fragment-based routing, this entire
|
||||||
|
-- `case url.fragment of` expression this comment
|
||||||
|
-- is inside would be unnecessary.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
Just _ ->
|
||||||
|
( model
|
||||||
|
, Nav.pushUrl (Session.navKey (toSession model)) (Url.toString url)
|
||||||
|
)
|
||||||
|
|
||||||
|
Browser.External href ->
|
||||||
|
( model
|
||||||
|
, Nav.load href
|
||||||
|
)
|
||||||
|
|
||||||
|
( ChangedUrl url, _ ) ->
|
||||||
|
changeRouteTo (Route.fromUrl url) model
|
||||||
|
|
||||||
|
( ChangedRoute route, _ ) ->
|
||||||
|
changeRouteTo route model
|
||||||
|
|
||||||
|
( GotSettingsMsg subMsg, Settings settings ) ->
|
||||||
|
Settings.update subMsg settings
|
||||||
|
|> updateWith Settings GotSettingsMsg model
|
||||||
|
|
||||||
|
( GotLoginMsg subMsg, Login login ) ->
|
||||||
|
Login.update subMsg login
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
( GotRegisterMsg subMsg, Register register ) ->
|
||||||
|
Register.update subMsg register
|
||||||
|
|> updateWith Register GotRegisterMsg model
|
||||||
|
|
||||||
|
( GotHomeMsg subMsg, Home home ) ->
|
||||||
|
Home.update subMsg home
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
( GotProfileMsg subMsg, Profile username profile ) ->
|
||||||
|
Profile.update subMsg profile
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
( GotArticleMsg subMsg, Article article ) ->
|
||||||
|
Article.update subMsg article
|
||||||
|
|> updateWith Article GotArticleMsg model
|
||||||
|
|
||||||
|
( GotEditorMsg subMsg, Editor slug editor ) ->
|
||||||
|
Editor.update subMsg editor
|
||||||
|
|> updateWith (Editor slug) GotEditorMsg model
|
||||||
|
|
||||||
|
( GotSession session, Redirect _ ) ->
|
||||||
|
( Redirect session
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
( _, _ ) ->
|
||||||
|
-- Disregard messages that arrived for the wrong page.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
updateWith : (subModel -> Model) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg )
|
||||||
|
updateWith toModel toMsg model ( subModel, subCmd ) =
|
||||||
|
( toModel subModel
|
||||||
|
, Cmd.map toMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
case model of
|
||||||
|
NotFound _ ->
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
Redirect _ ->
|
||||||
|
Session.changes GotSession (Session.navKey (toSession model))
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
Sub.map GotSettingsMsg (Settings.subscriptions settings)
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Sub.map GotHomeMsg (Home.subscriptions home)
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Sub.map GotLoginMsg (Login.subscriptions login)
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
Sub.map GotRegisterMsg (Register.subscriptions register)
|
||||||
|
|
||||||
|
Profile _ profile ->
|
||||||
|
Sub.map GotProfileMsg (Profile.subscriptions profile)
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
Sub.map GotArticleMsg (Article.subscriptions article)
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Sub.map GotEditorMsg (Editor.subscriptions editor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program Value Model Msg
|
||||||
|
main =
|
||||||
|
Browser.application
|
||||||
|
{ init = init
|
||||||
|
, onUrlChange = ChangedUrl
|
||||||
|
, onUrlRequest = ClickedLink
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
module Page exposing (Page(..), view, viewErrors)
|
||||||
|
|
||||||
|
import Avatar
|
||||||
|
import Browser exposing (Document)
|
||||||
|
import Html exposing (Html, a, button, div, footer, i, img, li, nav, p, span, text, ul)
|
||||||
|
import Html.Attributes exposing (class, classList, href, style)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Profile
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Determines which navbar link (if any) will be rendered as active.
|
||||||
|
|
||||||
|
Note that we don't enumerate every page here, because the navbar doesn't
|
||||||
|
have links for every page. Anything that's not part of the navbar falls
|
||||||
|
under Other.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Page
|
||||||
|
= Other
|
||||||
|
| Home
|
||||||
|
| Login
|
||||||
|
| Register
|
||||||
|
| Settings
|
||||||
|
| Profile Username
|
||||||
|
| NewArticle
|
||||||
|
|
||||||
|
|
||||||
|
{-| Take a page's Html and frames it with a header and footer.
|
||||||
|
|
||||||
|
The caller provides the current user, so we can display in either
|
||||||
|
"signed in" (rendering username) or "signed out" mode.
|
||||||
|
|
||||||
|
isLoading is for determining whether we should show a loading spinner
|
||||||
|
in the header. (This comes up during slow page transitions.)
|
||||||
|
|
||||||
|
-}
|
||||||
|
view : Maybe Viewer -> Page -> { title : String, content : Html msg } -> Document msg
|
||||||
|
view maybeViewer page { title, content } =
|
||||||
|
{ title = title ++ " - Conduit"
|
||||||
|
, body = viewHeader page maybeViewer :: content :: [ viewFooter ]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewHeader : Page -> Maybe Viewer -> Html msg
|
||||||
|
viewHeader page maybeViewer =
|
||||||
|
nav [ class "navbar navbar-light" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ a [ class "navbar-brand", Route.href Route.Home ]
|
||||||
|
[ text "conduit" ]
|
||||||
|
, ul [ class "nav navbar-nav pull-xs-right" ] <|
|
||||||
|
navbarLink page Route.Home [ text "Home" ]
|
||||||
|
:: viewMenu page maybeViewer
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewMenu : Page -> Maybe Viewer -> List (Html msg)
|
||||||
|
viewMenu page maybeViewer =
|
||||||
|
let
|
||||||
|
linkTo =
|
||||||
|
navbarLink page
|
||||||
|
in
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
|
||||||
|
username =
|
||||||
|
Cred.username cred
|
||||||
|
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Viewer.profile viewer)
|
||||||
|
in
|
||||||
|
[ linkTo Route.NewArticle [ i [ class "ion-compose" ] [], text " New Post" ]
|
||||||
|
, linkTo Route.Settings [ i [ class "ion-gear-a" ] [], text " Settings" ]
|
||||||
|
, linkTo
|
||||||
|
(Route.Profile username)
|
||||||
|
[ img [ class "user-pic", Avatar.src avatar ] []
|
||||||
|
, Username.toHtml username
|
||||||
|
]
|
||||||
|
, linkTo Route.Logout [ text "Sign out" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[ linkTo Route.Login [ text "Sign in" ]
|
||||||
|
, linkTo Route.Register [ text "Sign up" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewFooter : Html msg
|
||||||
|
viewFooter =
|
||||||
|
footer []
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ a [ class "logo-font", href "/" ] [ text "conduit" ]
|
||||||
|
, span [ class "attribution" ]
|
||||||
|
[ text "An interactive learning project from "
|
||||||
|
, a [ href "https://thinkster.io" ] [ text "Thinkster" ]
|
||||||
|
, text ". Code & design licensed under MIT."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
navbarLink : Page -> Route -> List (Html msg) -> Html msg
|
||||||
|
navbarLink page route linkContent =
|
||||||
|
li [ classList [ ( "nav-item", True ), ( "active", isActive page route ) ] ]
|
||||||
|
[ a [ class "nav-link", Route.href route ] linkContent ]
|
||||||
|
|
||||||
|
|
||||||
|
isActive : Page -> Route -> Bool
|
||||||
|
isActive page route =
|
||||||
|
case ( page, route ) of
|
||||||
|
( Home, Route.Home ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Login, Route.Login ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Register, Route.Register ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Settings, Route.Settings ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Profile pageUsername, Route.Profile routeUsername ) ->
|
||||||
|
pageUsername == routeUsername
|
||||||
|
|
||||||
|
( NewArticle, Route.NewArticle ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
|
||||||
|
{-| Render dismissable errors. We use this all over the place!
|
||||||
|
-}
|
||||||
|
viewErrors : msg -> List String -> Html msg
|
||||||
|
viewErrors dismissErrors errors =
|
||||||
|
if List.isEmpty errors then
|
||||||
|
Html.text ""
|
||||||
|
|
||||||
|
else
|
||||||
|
div
|
||||||
|
[ class "error-messages"
|
||||||
|
, style "position" "fixed"
|
||||||
|
, style "top" "0"
|
||||||
|
, style "background" "rgb(250, 250, 250)"
|
||||||
|
, style "padding" "20px"
|
||||||
|
, style "border" "1px solid"
|
||||||
|
]
|
||||||
|
<|
|
||||||
|
List.map (\error -> p [] [ text error ]) errors
|
||||||
|
++ [ button [ onClick dismissErrors ] [ text "Ok" ] ]
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
module Page.Article exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| Viewing an individual article.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Full, Preview)
|
||||||
|
import Article.Body exposing (Body)
|
||||||
|
import Article.Comment as Comment exposing (Comment)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor)
|
||||||
|
import Avatar
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import CommentId exposing (CommentId)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick, onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams)
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, errors : List String
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, comments : Status ( CommentText, List Comment )
|
||||||
|
, article : Status (Article Full)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading
|
||||||
|
| LoadingSlowly
|
||||||
|
| Loaded a
|
||||||
|
| Failed
|
||||||
|
|
||||||
|
|
||||||
|
type CommentText
|
||||||
|
= Editing String
|
||||||
|
| Sending String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> Slug -> ( Model, Cmd Msg )
|
||||||
|
init session slug =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, errors = []
|
||||||
|
, comments = Loading
|
||||||
|
, article = Loading
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Article.fetch maybeCred slug
|
||||||
|
|> Http.send CompletedLoadArticle
|
||||||
|
, Comment.list maybeCred slug
|
||||||
|
|> Http.send CompletedLoadComments
|
||||||
|
, Task.perform GotTimeZone Time.here
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
case model.article of
|
||||||
|
Loaded article ->
|
||||||
|
let
|
||||||
|
{ title } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
author =
|
||||||
|
Article.author article
|
||||||
|
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Author.profile author)
|
||||||
|
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
buttons =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
viewButtons cred article author
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, content =
|
||||||
|
div [ class "article-page" ]
|
||||||
|
[ div [ class "banner" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ h1 [] [ text title ]
|
||||||
|
, div [ class "article-meta" ] <|
|
||||||
|
List.append
|
||||||
|
[ a [ Route.href (Route.Profile (Author.username author)) ]
|
||||||
|
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view (Author.username author)
|
||||||
|
, Timestamp.view model.timeZone (Article.metadata article).createdAt
|
||||||
|
]
|
||||||
|
]
|
||||||
|
buttons
|
||||||
|
, Page.viewErrors ClickedDismissErrors model.errors
|
||||||
|
]
|
||||||
|
]
|
||||||
|
, div [ class "container page" ]
|
||||||
|
[ div [ class "row article-content" ]
|
||||||
|
[ div [ class "col-md-12" ]
|
||||||
|
[ Article.Body.toHtml (Article.body article) [] ]
|
||||||
|
]
|
||||||
|
, hr [] []
|
||||||
|
, div [ class "article-actions" ]
|
||||||
|
[ div [ class "article-meta" ] <|
|
||||||
|
List.append
|
||||||
|
[ a [ Route.href (Route.Profile (Author.username author)) ]
|
||||||
|
[ img [ Avatar.src avatar ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view (Author.username author)
|
||||||
|
, Timestamp.view model.timeZone (Article.metadata article).createdAt
|
||||||
|
]
|
||||||
|
]
|
||||||
|
buttons
|
||||||
|
]
|
||||||
|
, div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-8 offset-md-2" ] <|
|
||||||
|
-- Don't render the comments until the article has loaded!
|
||||||
|
case model.comments of
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Loaded ( commentText, comments ) ->
|
||||||
|
-- Don't let users add comments until they can
|
||||||
|
-- see the existing comments! Otherwise you
|
||||||
|
-- may be about to repeat something that's
|
||||||
|
-- already been said.
|
||||||
|
viewAddComment slug commentText (Session.viewer model.session)
|
||||||
|
:: List.map (viewComment model.timeZone slug) comments
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "comments" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
{ title = "Article", content = text "" }
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
{ title = "Article", content = Loading.icon }
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
{ title = "Article", content = Loading.error "article" }
|
||||||
|
|
||||||
|
|
||||||
|
viewAddComment : Slug -> CommentText -> Maybe Viewer -> Html Msg
|
||||||
|
viewAddComment slug commentText maybeViewer =
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Viewer.profile viewer)
|
||||||
|
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
|
||||||
|
( commentStr, buttonAttrs ) =
|
||||||
|
case commentText of
|
||||||
|
Editing str ->
|
||||||
|
( str, [] )
|
||||||
|
|
||||||
|
Sending str ->
|
||||||
|
( str, [ disabled True ] )
|
||||||
|
in
|
||||||
|
Html.form [ class "card comment-form", onSubmit (ClickedPostComment cred slug) ]
|
||||||
|
[ div [ class "card-block" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Write a comment..."
|
||||||
|
, attribute "rows" "3"
|
||||||
|
, onInput EnteredCommentText
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div [ class "card-footer" ]
|
||||||
|
[ img [ class "comment-author-img", Avatar.src avatar ] []
|
||||||
|
, button
|
||||||
|
(class "btn btn-sm btn-primary" :: buttonAttrs)
|
||||||
|
[ text "Post Comment" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
p []
|
||||||
|
[ a [ Route.href Route.Login ] [ text "Sign in" ]
|
||||||
|
, text " or "
|
||||||
|
, a [ Route.href Route.Register ] [ text "sign up" ]
|
||||||
|
, text " to comment."
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewButtons : Cred -> Article Full -> Author -> List (Html Msg)
|
||||||
|
viewButtons cred article author =
|
||||||
|
case author of
|
||||||
|
IsFollowing followedAuthor ->
|
||||||
|
[ Author.unfollowButton ClickedUnfollow cred followedAuthor
|
||||||
|
, text " "
|
||||||
|
, favoriteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
IsNotFollowing unfollowedAuthor ->
|
||||||
|
[ Author.followButton ClickedFollow cred unfollowedAuthor
|
||||||
|
, text " "
|
||||||
|
, favoriteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
IsViewer _ _ ->
|
||||||
|
[ editButton article
|
||||||
|
, text " "
|
||||||
|
, deleteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewComment : Time.Zone -> Slug -> Comment -> Html Msg
|
||||||
|
viewComment timeZone slug comment =
|
||||||
|
let
|
||||||
|
author =
|
||||||
|
Comment.author comment
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
authorUsername =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
deleteCommentButton =
|
||||||
|
case author of
|
||||||
|
IsViewer cred _ ->
|
||||||
|
let
|
||||||
|
msg =
|
||||||
|
ClickedDeleteComment cred slug (Comment.id comment)
|
||||||
|
in
|
||||||
|
span
|
||||||
|
[ class "mod-options"
|
||||||
|
, onClick msg
|
||||||
|
]
|
||||||
|
[ i [ class "ion-trash-a" ] [] ]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- You can't delete other peoples' comments!
|
||||||
|
text ""
|
||||||
|
|
||||||
|
timestamp =
|
||||||
|
Timestamp.format timeZone (Comment.createdAt comment)
|
||||||
|
in
|
||||||
|
div [ class "card" ]
|
||||||
|
[ div [ class "card-block" ]
|
||||||
|
[ p [ class "card-text" ] [ text (Comment.body comment) ] ]
|
||||||
|
, div [ class "card-footer" ]
|
||||||
|
[ a [ class "comment-author", href "" ]
|
||||||
|
[ img [ class "comment-author-img", Avatar.src (Profile.avatar profile) ] []
|
||||||
|
, text " "
|
||||||
|
]
|
||||||
|
, text " "
|
||||||
|
, a [ class "comment-author", Route.href (Route.Profile authorUsername) ]
|
||||||
|
[ text (Username.toString authorUsername) ]
|
||||||
|
, span [ class "date-posted" ] [ text timestamp ]
|
||||||
|
, deleteCommentButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDeleteArticle Cred Slug
|
||||||
|
| ClickedDeleteComment Cred Slug CommentId
|
||||||
|
| ClickedDismissErrors
|
||||||
|
| ClickedFavorite Cred Slug Body
|
||||||
|
| ClickedUnfavorite Cred Slug Body
|
||||||
|
| ClickedFollow Cred UnfollowedAuthor
|
||||||
|
| ClickedUnfollow Cred FollowedAuthor
|
||||||
|
| ClickedPostComment Cred Slug
|
||||||
|
| EnteredCommentText String
|
||||||
|
| CompletedLoadArticle (Result Http.Error (Article Full))
|
||||||
|
| CompletedLoadComments (Result Http.Error (List Comment))
|
||||||
|
| CompletedDeleteArticle (Result Http.Error ())
|
||||||
|
| CompletedDeleteComment CommentId (Result Http.Error ())
|
||||||
|
| CompletedFavoriteChange (Result Http.Error (Article Full))
|
||||||
|
| CompletedFollowChange (Result Http.Error Author)
|
||||||
|
| CompletedPostComment (Result Http.Error Comment)
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedFavorite cred slug body ->
|
||||||
|
( model, fave Article.favorite cred slug body )
|
||||||
|
|
||||||
|
ClickedUnfavorite cred slug body ->
|
||||||
|
( model, fave Article.unfavorite cred slug body )
|
||||||
|
|
||||||
|
CompletedLoadArticle (Ok article) ->
|
||||||
|
( { model | article = Loaded article }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedLoadArticle (Err error) ->
|
||||||
|
( { model | article = Failed }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedLoadComments (Ok comments) ->
|
||||||
|
( { model | comments = Loaded ( Editing "", comments ) }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedLoadComments (Err error) ->
|
||||||
|
( { model | article = Failed }, Log.error )
|
||||||
|
|
||||||
|
CompletedFavoriteChange (Ok newArticle) ->
|
||||||
|
( { model | article = Loaded newArticle }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedFavoriteChange (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedUnfollow cred followedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestUnfollow followedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFollow cred unfollowedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestFollow unfollowedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Ok newAuthor) ->
|
||||||
|
case model.article of
|
||||||
|
Loaded article ->
|
||||||
|
( { model | article = Loaded (Article.mapAuthor (\_ -> newAuthor) article) }, Cmd.none )
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedFollowChange (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredCommentText str ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( Editing _, comments ) ->
|
||||||
|
-- You can only edit comment text once comments have loaded
|
||||||
|
-- successfully, and when the comment is not currently
|
||||||
|
-- being submitted.
|
||||||
|
( { model | comments = Loaded ( Editing str, comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
ClickedPostComment cred slug ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( Editing "", comments ) ->
|
||||||
|
-- No posting empty comments!
|
||||||
|
-- We don't use Log.error here because this isn't an error,
|
||||||
|
-- it just doesn't do anything.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
Loaded ( Editing str, comments ) ->
|
||||||
|
( { model | comments = Loaded ( Sending str, comments ) }
|
||||||
|
, cred
|
||||||
|
|> Comment.post slug str
|
||||||
|
|> Http.send CompletedPostComment
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- Either we have no comment to post, or there's already
|
||||||
|
-- one in the process of being posted, or we don't have
|
||||||
|
-- a valid article, in which case how did we post this?
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedPostComment (Ok comment) ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( _, comments ) ->
|
||||||
|
( { model | comments = Loaded ( Editing "", comment :: comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedPostComment (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedDeleteComment cred slug id ->
|
||||||
|
( model
|
||||||
|
, cred
|
||||||
|
|> Comment.delete slug id
|
||||||
|
|> Http.send (CompletedDeleteComment id)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedDeleteComment id (Ok ()) ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( commentText, comments ) ->
|
||||||
|
( { model | comments = Loaded ( commentText, withoutComment id comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedDeleteComment id (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedDeleteArticle cred slug ->
|
||||||
|
( model
|
||||||
|
, delete slug cred
|
||||||
|
|> Http.send CompletedDeleteArticle
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedDeleteArticle (Ok ()) ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey model.session) Route.Home )
|
||||||
|
|
||||||
|
CompletedDeleteArticle (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
article =
|
||||||
|
case model.article of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|
||||||
|
comments =
|
||||||
|
case model.comments of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | article = article, comments = comments }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
delete : Slug -> Cred -> Http.Request ()
|
||||||
|
delete slug cred =
|
||||||
|
Article.url slug []
|
||||||
|
|> HttpBuilder.delete
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Body -> Cmd Msg
|
||||||
|
fave toRequest cred slug body =
|
||||||
|
toRequest slug cred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.map (Article.fromPreview body)
|
||||||
|
|> Task.attempt CompletedFavoriteChange
|
||||||
|
|
||||||
|
|
||||||
|
withoutComment : CommentId -> List Comment -> List Comment
|
||||||
|
withoutComment id list =
|
||||||
|
List.filter (\comment -> Comment.id comment /= id) list
|
||||||
|
|
||||||
|
|
||||||
|
favoriteButton : Cred -> Article Full -> Html Msg
|
||||||
|
favoriteButton cred article =
|
||||||
|
let
|
||||||
|
{ favoritesCount, favorited } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
body =
|
||||||
|
Article.body article
|
||||||
|
|
||||||
|
kids =
|
||||||
|
[ text (" Favorite Article (" ++ String.fromInt favoritesCount ++ ")") ]
|
||||||
|
in
|
||||||
|
if favorited then
|
||||||
|
Article.unfavoriteButton cred (ClickedUnfavorite cred slug body) [] kids
|
||||||
|
|
||||||
|
else
|
||||||
|
Article.favoriteButton cred (ClickedFavorite cred slug body) [] kids
|
||||||
|
|
||||||
|
|
||||||
|
deleteButton : Cred -> Article a -> Html Msg
|
||||||
|
deleteButton cred article =
|
||||||
|
let
|
||||||
|
msg =
|
||||||
|
ClickedDeleteArticle cred (Article.slug article)
|
||||||
|
in
|
||||||
|
button [ class "btn btn-outline-danger btn-sm", onClick msg ]
|
||||||
|
[ i [ class "ion-trash-a" ] [], text " Delete Article" ]
|
||||||
|
|
||||||
|
|
||||||
|
editButton : Article a -> Html Msg
|
||||||
|
editButton article =
|
||||||
|
a [ class "btn btn-outline-secondary btn-sm", Route.href (Route.EditArticle (Article.slug article)) ]
|
||||||
|
[ i [ class "ion-edit" ] [], text " Edit Article" ]
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
module Page.Article.Editor exposing (Model, Msg, initEdit, initNew, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Full)
|
||||||
|
import Article.Body exposing (Body)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder, type_, value)
|
||||||
|
import Html.Events exposing (onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (withBody, withExpect)
|
||||||
|
import Json.Decode as Decode
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Loading
|
||||||
|
import Page
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, status : Status
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type
|
||||||
|
Status
|
||||||
|
-- Edit Article
|
||||||
|
= Loading Slug
|
||||||
|
| LoadingSlowly Slug
|
||||||
|
| LoadingFailed Slug
|
||||||
|
| Saving Slug Form
|
||||||
|
| Editing Slug (List Problem) Form
|
||||||
|
-- New Article
|
||||||
|
| EditingNew (List Problem) Form
|
||||||
|
| Creating Form
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ title : String
|
||||||
|
, body : String
|
||||||
|
, description : String
|
||||||
|
, tags : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
initNew : Session -> ( Model, Cmd msg )
|
||||||
|
initNew session =
|
||||||
|
( { session = session
|
||||||
|
, status =
|
||||||
|
EditingNew []
|
||||||
|
{ title = ""
|
||||||
|
, body = ""
|
||||||
|
, description = ""
|
||||||
|
, tags = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
initEdit : Session -> Slug -> ( Model, Cmd Msg )
|
||||||
|
initEdit session slug =
|
||||||
|
( { session = session
|
||||||
|
, status = Loading slug
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Article.fetch (Session.cred session) slug
|
||||||
|
|> Http.toTask
|
||||||
|
-- If init fails, store the slug that failed in the msg, so we can
|
||||||
|
-- at least have it later to display the page's title properly!
|
||||||
|
|> Task.mapError (\httpError -> ( slug, httpError ))
|
||||||
|
|> Task.attempt CompletedArticleLoad
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title =
|
||||||
|
case getSlug model.status of
|
||||||
|
Just slug ->
|
||||||
|
"Edit Article - " ++ Slug.toString slug
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"New Article"
|
||||||
|
, content =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
viewAuthenticated cred model
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text "Sign in to edit this article."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewProblems : List Problem -> Html msg
|
||||||
|
viewProblems problems =
|
||||||
|
ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem problems)
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ message ->
|
||||||
|
message
|
||||||
|
|
||||||
|
ServerError message ->
|
||||||
|
message
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
viewAuthenticated : Cred -> Model -> Html Msg
|
||||||
|
viewAuthenticated cred model =
|
||||||
|
let
|
||||||
|
formHtml =
|
||||||
|
case model.status of
|
||||||
|
Loading _ ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Saving slug form ->
|
||||||
|
[ viewForm cred form (editArticleSaveButton [ disabled True ]) ]
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
[ viewForm cred form (newArticleSaveButton [ disabled True ]) ]
|
||||||
|
|
||||||
|
Editing slug problems form ->
|
||||||
|
[ viewProblems problems
|
||||||
|
, viewForm cred form (editArticleSaveButton [])
|
||||||
|
]
|
||||||
|
|
||||||
|
EditingNew problems form ->
|
||||||
|
[ viewProblems problems
|
||||||
|
, viewForm cred form (newArticleSaveButton [])
|
||||||
|
]
|
||||||
|
|
||||||
|
LoadingFailed _ ->
|
||||||
|
[ text "Article failed to load." ]
|
||||||
|
in
|
||||||
|
div [ class "editor-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-10 offset-md-1 col-xs-12" ]
|
||||||
|
formHtml
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Cred -> Form -> Html Msg -> Html Msg
|
||||||
|
viewForm cred fields saveButton =
|
||||||
|
Html.form [ onSubmit (ClickedSave cred) ]
|
||||||
|
[ fieldset []
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Article Title"
|
||||||
|
, onInput EnteredTitle
|
||||||
|
, value fields.title
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "What's this article about?"
|
||||||
|
, onInput EnteredDescription
|
||||||
|
, value fields.description
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Write your article (in markdown)"
|
||||||
|
, attribute "rows" "8"
|
||||||
|
, onInput EnteredBody
|
||||||
|
, value fields.body
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Enter tags"
|
||||||
|
, onInput EnteredTags
|
||||||
|
, value fields.tags
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, saveButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
editArticleSaveButton : List (Attribute msg) -> Html msg
|
||||||
|
editArticleSaveButton extraAttrs =
|
||||||
|
saveArticleButton "Update Article" extraAttrs
|
||||||
|
|
||||||
|
|
||||||
|
newArticleSaveButton : List (Attribute msg) -> Html msg
|
||||||
|
newArticleSaveButton extraAttrs =
|
||||||
|
saveArticleButton "Publish Article" extraAttrs
|
||||||
|
|
||||||
|
|
||||||
|
saveArticleButton : String -> List (Attribute msg) -> Html msg
|
||||||
|
saveArticleButton caption extraAttrs =
|
||||||
|
button (class "btn btn-lg pull-xs-right btn-primary" :: extraAttrs)
|
||||||
|
[ text caption ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedSave Cred
|
||||||
|
| EnteredBody String
|
||||||
|
| EnteredDescription String
|
||||||
|
| EnteredTags String
|
||||||
|
| EnteredTitle String
|
||||||
|
| CompletedCreate (Result Http.Error (Article Full))
|
||||||
|
| CompletedEdit (Result Http.Error (Article Full))
|
||||||
|
| CompletedArticleLoad (Result ( Slug, Http.Error ) (Article Full))
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedSave cred ->
|
||||||
|
model.status
|
||||||
|
|> save cred
|
||||||
|
|> Tuple.mapFirst (\status -> { model | status = status })
|
||||||
|
|
||||||
|
EnteredTitle title ->
|
||||||
|
updateForm (\form -> { form | title = title }) model
|
||||||
|
|
||||||
|
EnteredDescription description ->
|
||||||
|
updateForm (\form -> { form | description = description }) model
|
||||||
|
|
||||||
|
EnteredTags tags ->
|
||||||
|
updateForm (\form -> { form | tags = tags }) model
|
||||||
|
|
||||||
|
EnteredBody body ->
|
||||||
|
updateForm (\form -> { form | body = body }) model
|
||||||
|
|
||||||
|
CompletedCreate (Ok article) ->
|
||||||
|
( model
|
||||||
|
, Route.Article (Article.slug article)
|
||||||
|
|> Route.replaceUrl (Session.navKey model.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedCreate (Err error) ->
|
||||||
|
( { model | status = savingError error model.status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedEdit (Ok article) ->
|
||||||
|
( model
|
||||||
|
, Route.Article (Article.slug article)
|
||||||
|
|> Route.replaceUrl (Session.navKey model.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedEdit (Err error) ->
|
||||||
|
( { model | status = savingError error model.status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedArticleLoad (Err ( slug, error )) ->
|
||||||
|
( { model | status = LoadingFailed slug }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedArticleLoad (Ok article) ->
|
||||||
|
let
|
||||||
|
{ title, description, tags } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
status =
|
||||||
|
Editing (Article.slug article)
|
||||||
|
[]
|
||||||
|
{ title = title
|
||||||
|
, body = Article.Body.toMarkdownString (Article.body article)
|
||||||
|
, description = description
|
||||||
|
, tags = String.join " " tags
|
||||||
|
}
|
||||||
|
in
|
||||||
|
( { model | status = status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
status =
|
||||||
|
case model.status of
|
||||||
|
Loading slug ->
|
||||||
|
LoadingSlowly slug
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | status = status }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
save : Cred -> Status -> ( Status, Cmd Msg )
|
||||||
|
save cred status =
|
||||||
|
case status of
|
||||||
|
Editing slug _ form ->
|
||||||
|
case validate form of
|
||||||
|
Ok validForm ->
|
||||||
|
( Saving slug form
|
||||||
|
, edit slug validForm cred
|
||||||
|
|> Http.send CompletedEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( Editing slug problems form
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EditingNew _ form ->
|
||||||
|
case validate form of
|
||||||
|
Ok validForm ->
|
||||||
|
( Creating form
|
||||||
|
, create validForm cred
|
||||||
|
|> Http.send CompletedCreate
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( EditingNew problems form
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- We're in a state where saving is not allowed.
|
||||||
|
-- We tried to prevent getting here by disabling the Save
|
||||||
|
-- button, but somehow the user got here anyway!
|
||||||
|
--
|
||||||
|
-- If we had an error logging service, we would send
|
||||||
|
-- something to it here!
|
||||||
|
( status, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
savingError : Http.Error -> Status -> Status
|
||||||
|
savingError error status =
|
||||||
|
let
|
||||||
|
problems =
|
||||||
|
[ ServerError "Error saving article" ]
|
||||||
|
in
|
||||||
|
case status of
|
||||||
|
Saving slug form ->
|
||||||
|
Editing slug problems form
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
EditingNew problems form
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
status
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form, if there is one,
|
||||||
|
and returns Cmd.none.
|
||||||
|
|
||||||
|
Useful for recording form fields!
|
||||||
|
|
||||||
|
This could also log errors to the server if we are trying to record things in
|
||||||
|
the form and we don't actually have a form.
|
||||||
|
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
let
|
||||||
|
newModel =
|
||||||
|
case model.status of
|
||||||
|
Loading _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
LoadingFailed _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
Saving slug form ->
|
||||||
|
{ model | status = Saving slug (transform form) }
|
||||||
|
|
||||||
|
Editing slug errors form ->
|
||||||
|
{ model | status = Editing slug errors (transform form) }
|
||||||
|
|
||||||
|
EditingNew errors form ->
|
||||||
|
{ model | status = EditingNew errors (transform form) }
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
{ model | status = Creating (transform form) }
|
||||||
|
in
|
||||||
|
( newModel, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Title
|
||||||
|
| Body
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Title
|
||||||
|
, Body
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Title ->
|
||||||
|
if String.isEmpty form.title then
|
||||||
|
[ "title can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Body ->
|
||||||
|
if String.isEmpty form.body then
|
||||||
|
[ "body can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ title = String.trim form.title
|
||||||
|
, body = String.trim form.body
|
||||||
|
, description = String.trim form.description
|
||||||
|
, tags = String.trim form.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
create : TrimmedForm -> Cred -> Http.Request (Article Full)
|
||||||
|
create (Trimmed form) cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
Article.fullDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
|
||||||
|
article =
|
||||||
|
Encode.object
|
||||||
|
[ ( "title", Encode.string form.title )
|
||||||
|
, ( "description", Encode.string form.description )
|
||||||
|
, ( "body", Encode.string form.body )
|
||||||
|
, ( "tagList", Encode.list Encode.string (tagsFromString form.tags) )
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonBody =
|
||||||
|
Encode.object [ ( "article", article ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.post
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withBody jsonBody
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
tagsFromString : String -> List String
|
||||||
|
tagsFromString str =
|
||||||
|
String.split " " str
|
||||||
|
|> List.map String.trim
|
||||||
|
|> List.filter (not << String.isEmpty)
|
||||||
|
|
||||||
|
|
||||||
|
edit : Slug -> TrimmedForm -> Cred -> Http.Request (Article Full)
|
||||||
|
edit articleSlug (Trimmed form) cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
Article.fullDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
|
||||||
|
article =
|
||||||
|
Encode.object
|
||||||
|
[ ( "title", Encode.string form.title )
|
||||||
|
, ( "description", Encode.string form.description )
|
||||||
|
, ( "body", Encode.string form.body )
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonBody =
|
||||||
|
Encode.object [ ( "article", article ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Article.url articleSlug []
|
||||||
|
|> HttpBuilder.put
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withBody jsonBody
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
{-| Used for setting the page's title.
|
||||||
|
-}
|
||||||
|
getSlug : Status -> Maybe Slug
|
||||||
|
getSlug status =
|
||||||
|
case status of
|
||||||
|
Loading slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
LoadingSlowly slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
LoadingFailed slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
Saving slug _ ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
Editing slug _ _ ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
EditingNew _ _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
Creating _ ->
|
||||||
|
Nothing
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module Page.Blank exposing (view)
|
||||||
|
|
||||||
|
import Html exposing (Html)
|
||||||
|
|
||||||
|
|
||||||
|
view : { title : String, content : Html msg }
|
||||||
|
view =
|
||||||
|
{ title = ""
|
||||||
|
, content = Html.text ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
module Page.Home exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| The homepage. You can get here via either the / or /#/ routes.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.Feed as Feed
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Browser.Dom as Dom
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, feedTab : FeedTab
|
||||||
|
, feedPage : Int
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, tags : Status (List Tag)
|
||||||
|
, feed : Status Feed.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading
|
||||||
|
| LoadingSlowly
|
||||||
|
| Loaded a
|
||||||
|
| Failed
|
||||||
|
|
||||||
|
|
||||||
|
type FeedTab
|
||||||
|
= YourFeed Cred
|
||||||
|
| GlobalFeed
|
||||||
|
| TagFeed Tag
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd Msg )
|
||||||
|
init session =
|
||||||
|
let
|
||||||
|
feedTab =
|
||||||
|
case Session.cred session of
|
||||||
|
Just cred ->
|
||||||
|
YourFeed cred
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
GlobalFeed
|
||||||
|
|
||||||
|
loadTags =
|
||||||
|
Http.toTask Tag.list
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, feedTab = feedTab
|
||||||
|
, feedPage = 1
|
||||||
|
, tags = Loading
|
||||||
|
, feed = Loading
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ fetchFeed session feedTab 1
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
, Tag.list
|
||||||
|
|> Http.send CompletedTagsLoad
|
||||||
|
, Task.perform GotTimeZone Time.here
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Conduit"
|
||||||
|
, content =
|
||||||
|
div [ class "home-page" ]
|
||||||
|
[ viewBanner
|
||||||
|
, div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-9" ] <|
|
||||||
|
case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
[ div [ class "feed-toggle" ] <|
|
||||||
|
List.concat
|
||||||
|
[ [ viewTabs
|
||||||
|
(Session.cred model.session)
|
||||||
|
model.feedTab
|
||||||
|
]
|
||||||
|
, Feed.viewArticles model.timeZone feed
|
||||||
|
|> List.map (Html.map GotFeedMsg)
|
||||||
|
, [ Feed.viewPagination ClickedFeedPage feed ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "feed" ]
|
||||||
|
, div [ class "col-md-3" ] <|
|
||||||
|
case model.tags of
|
||||||
|
Loaded tags ->
|
||||||
|
[ div [ class "sidebar" ] <|
|
||||||
|
[ p [] [ text "Popular Tags" ]
|
||||||
|
, viewTags tags
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "tags" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewBanner : Html msg
|
||||||
|
viewBanner =
|
||||||
|
div [ class "banner" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ h1 [ class "logo-font" ] [ text "conduit" ]
|
||||||
|
, p [] [ text "A place to share your knowledge." ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TABS
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs : Maybe Cred -> FeedTab -> Html Msg
|
||||||
|
viewTabs maybeCred tab =
|
||||||
|
case tab of
|
||||||
|
YourFeed cred ->
|
||||||
|
Feed.viewTabs [] (yourFeed cred) [ globalFeed ]
|
||||||
|
|
||||||
|
GlobalFeed ->
|
||||||
|
let
|
||||||
|
otherTabs =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
[ yourFeed cred ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
Feed.viewTabs otherTabs globalFeed []
|
||||||
|
|
||||||
|
TagFeed tag ->
|
||||||
|
let
|
||||||
|
otherTabs =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
[ yourFeed cred, globalFeed ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[ globalFeed ]
|
||||||
|
in
|
||||||
|
Feed.viewTabs otherTabs (tagFeed tag) []
|
||||||
|
|
||||||
|
|
||||||
|
yourFeed : Cred -> ( String, Msg )
|
||||||
|
yourFeed cred =
|
||||||
|
( "Your Feed", ClickedTab (YourFeed cred) )
|
||||||
|
|
||||||
|
|
||||||
|
globalFeed : ( String, Msg )
|
||||||
|
globalFeed =
|
||||||
|
( "Global Feed", ClickedTab GlobalFeed )
|
||||||
|
|
||||||
|
|
||||||
|
tagFeed : Tag -> ( String, Msg )
|
||||||
|
tagFeed tag =
|
||||||
|
( "#" ++ Tag.toString tag, ClickedTab (TagFeed tag) )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TAGS
|
||||||
|
|
||||||
|
|
||||||
|
viewTags : List Tag -> Html Msg
|
||||||
|
viewTags tags =
|
||||||
|
div [ class "tag-list" ] (List.map viewTag tags)
|
||||||
|
|
||||||
|
|
||||||
|
viewTag : Tag -> Html Msg
|
||||||
|
viewTag tagName =
|
||||||
|
a
|
||||||
|
[ class "tag-pill tag-default"
|
||||||
|
, onClick (ClickedTag tagName)
|
||||||
|
|
||||||
|
-- The RealWorld CSS requires an href to work properly.
|
||||||
|
, href ""
|
||||||
|
]
|
||||||
|
[ text (Tag.toString tagName) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedTag Tag
|
||||||
|
| ClickedTab FeedTab
|
||||||
|
| ClickedFeedPage Int
|
||||||
|
| CompletedFeedLoad (Result Http.Error Feed.Model)
|
||||||
|
| CompletedTagsLoad (Result Http.Error (List Tag))
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotFeedMsg Feed.Msg
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedTag tag ->
|
||||||
|
let
|
||||||
|
feedTab =
|
||||||
|
TagFeed tag
|
||||||
|
in
|
||||||
|
( { model | feedTab = feedTab }
|
||||||
|
, fetchFeed model.session feedTab 1
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedTab tab ->
|
||||||
|
( { model | feedTab = tab }
|
||||||
|
, fetchFeed model.session tab 1
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFeedPage page ->
|
||||||
|
( { model | feedPage = page }
|
||||||
|
, fetchFeed model.session model.feedTab page
|
||||||
|
|> Task.andThen (\feed -> Task.map (\_ -> feed) scrollToTop)
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFeedLoad (Ok feed) ->
|
||||||
|
( { model | feed = Loaded feed }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedFeedLoad (Err error) ->
|
||||||
|
( { model | feed = Failed }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedTagsLoad (Ok tags) ->
|
||||||
|
( { model | tags = Loaded tags }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedTagsLoad (Err error) ->
|
||||||
|
( { model | tags = Failed }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotFeedMsg subMsg ->
|
||||||
|
case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
let
|
||||||
|
( newFeed, subCmd ) =
|
||||||
|
Feed.update (Session.cred model.session) subMsg feed
|
||||||
|
in
|
||||||
|
( { model | feed = Loaded newFeed }
|
||||||
|
, Cmd.map GotFeedMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }, Cmd.none )
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
feed =
|
||||||
|
case model.feed of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|
||||||
|
tags =
|
||||||
|
case model.tags of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | feed = feed, tags = tags }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
fetchFeed : Session -> FeedTab -> Int -> Task Http.Error Feed.Model
|
||||||
|
fetchFeed session feedTabs page =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
builder =
|
||||||
|
case feedTabs of
|
||||||
|
YourFeed cred ->
|
||||||
|
Api.url [ "articles", "feed" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|
||||||
|
GlobalFeed ->
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|
||||||
|
TagFeed tag ->
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.withQueryParam "tag" (Tag.toString tag)
|
||||||
|
in
|
||||||
|
builder
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Feed.decoder maybeCred articlesPerPage))
|
||||||
|
|> PaginatedList.fromRequestBuilder articlesPerPage page
|
||||||
|
|> Task.map (Feed.init session)
|
||||||
|
|
||||||
|
|
||||||
|
articlesPerPage : Int
|
||||||
|
articlesPerPage =
|
||||||
|
10
|
||||||
|
|
||||||
|
|
||||||
|
scrollToTop : Task x ()
|
||||||
|
scrollToTop =
|
||||||
|
Dom.setViewport 0 0
|
||||||
|
-- It's not worth showing the user anything special if scrolling fails.
|
||||||
|
-- If anything, we'd log this to an error recording service.
|
||||||
|
|> Task.onError (\_ -> Task.succeed ())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
module Page.Login exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| The login page.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, problems : List Problem
|
||||||
|
, form : Form
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
{-| Recording validation problems on a per-field basis facilitates displaying
|
||||||
|
them inline next to the field where the error occurred.
|
||||||
|
|
||||||
|
I implemented it this way out of habit, then realized the spec called for
|
||||||
|
displaying all the errors at the top. I thought about simplifying it, but then
|
||||||
|
figured it'd be useful to show how I would normally model this data - assuming
|
||||||
|
the intended UX was to render errors per field.
|
||||||
|
|
||||||
|
(The other part of this is having a view function like this:
|
||||||
|
|
||||||
|
viewFieldErrors : ValidatedField -> List Problem -> Html msg
|
||||||
|
|
||||||
|
...and it filters the list of problems to render only InvalidEntry ones for the
|
||||||
|
given ValidatedField. That way you can call this:
|
||||||
|
|
||||||
|
viewFieldErrors Email problems
|
||||||
|
|
||||||
|
...next to the `email` field, and call `viewFieldErrors Password problems`
|
||||||
|
next to the `password` field, and so on.
|
||||||
|
|
||||||
|
The `LoginError` should be displayed elsewhere, since it doesn't correspond to
|
||||||
|
a particular field.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ email : String
|
||||||
|
, password : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd msg )
|
||||||
|
init session =
|
||||||
|
( { session = session
|
||||||
|
, problems = []
|
||||||
|
, form =
|
||||||
|
{ email = ""
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Login"
|
||||||
|
, content =
|
||||||
|
div [ class "cred-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-6 offset-md-3 col-xs-12" ]
|
||||||
|
[ h1 [ class "text-xs-center" ] [ text "Sign in" ]
|
||||||
|
, p [ class "text-xs-center" ]
|
||||||
|
[ a [ Route.href Route.Register ]
|
||||||
|
[ text "Need an account?" ]
|
||||||
|
]
|
||||||
|
, ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem model.problems)
|
||||||
|
, viewForm model.form
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ str ->
|
||||||
|
str
|
||||||
|
|
||||||
|
ServerError str ->
|
||||||
|
str
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Form -> Html Msg
|
||||||
|
viewForm form =
|
||||||
|
Html.form [ onSubmit SubmittedForm ]
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Email"
|
||||||
|
, onInput EnteredEmail
|
||||||
|
, value form.email
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, type_ "password"
|
||||||
|
, placeholder "Password"
|
||||||
|
, onInput EnteredPassword
|
||||||
|
, value form.password
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button [ class "btn btn-lg btn-primary pull-xs-right" ]
|
||||||
|
[ text "Sign in" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SubmittedForm
|
||||||
|
| EnteredEmail String
|
||||||
|
| EnteredPassword String
|
||||||
|
| CompletedLogin (Result Http.Error Viewer)
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
SubmittedForm ->
|
||||||
|
case validate model.form of
|
||||||
|
Ok validForm ->
|
||||||
|
( { model | problems = [] }
|
||||||
|
, Http.send CompletedLogin (login validForm)
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( { model | problems = problems }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredEmail email ->
|
||||||
|
updateForm (\form -> { form | email = email }) model
|
||||||
|
|
||||||
|
EnteredPassword password ->
|
||||||
|
updateForm (\form -> { form | password = password }) model
|
||||||
|
|
||||||
|
CompletedLogin (Err error) ->
|
||||||
|
let
|
||||||
|
serverErrors =
|
||||||
|
Api.decodeErrors error
|
||||||
|
|> List.map ServerError
|
||||||
|
in
|
||||||
|
( { model | problems = List.append model.problems serverErrors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedLogin (Ok viewer) ->
|
||||||
|
( model
|
||||||
|
, Session.login viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form and returns Cmd.none and
|
||||||
|
Ignored. Useful for recording form fields!
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
( { model | form = transform model.form }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Email
|
||||||
|
| Password
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Email
|
||||||
|
, Password
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Email ->
|
||||||
|
if String.isEmpty form.email then
|
||||||
|
[ "email can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Password ->
|
||||||
|
if String.isEmpty form.password then
|
||||||
|
[ "password can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ email = String.trim form.email
|
||||||
|
, password = String.trim form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
login : TrimmedForm -> Http.Request Viewer
|
||||||
|
login (Trimmed form) =
|
||||||
|
let
|
||||||
|
user =
|
||||||
|
Encode.object
|
||||||
|
[ ( "email", Encode.string form.email )
|
||||||
|
, ( "password", Encode.string form.password )
|
||||||
|
]
|
||||||
|
|
||||||
|
body =
|
||||||
|
Encode.object [ ( "user", user ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Decode.field "user" Viewer.decoder
|
||||||
|
|> Http.post (Api.url [ "users", "login" ]) body
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module Page.NotFound exposing (view)
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Html, div, h1, img, main_, text)
|
||||||
|
import Html.Attributes exposing (alt, class, id, src, tabindex)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : { title : String, content : Html msg }
|
||||||
|
view =
|
||||||
|
{ title = "Page Not Found"
|
||||||
|
, content =
|
||||||
|
main_ [ id "content", class "container", tabindex -1 ]
|
||||||
|
[ h1 [] [ text "Not Found" ]
|
||||||
|
, div [ class "row" ]
|
||||||
|
[ img [ Asset.src Asset.error ] [] ]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
module Page.Profile exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| An Author's profile.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.Feed as Feed
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor)
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, errors : List String
|
||||||
|
, feedTab : FeedTab
|
||||||
|
, feedPage : Int
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, author : Status Author
|
||||||
|
, feed : Status Feed.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type FeedTab
|
||||||
|
= MyArticles
|
||||||
|
| FavoritedArticles
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading Username
|
||||||
|
| LoadingSlowly Username
|
||||||
|
| Loaded a
|
||||||
|
| Failed Username
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> Username -> ( Model, Cmd Msg )
|
||||||
|
init session username =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, errors = []
|
||||||
|
, feedTab = defaultFeedTab
|
||||||
|
, feedPage = 1
|
||||||
|
, author = Loading username
|
||||||
|
, feed = Loading username
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Author.fetch username maybeCred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.mapError (Tuple.pair username)
|
||||||
|
|> Task.attempt CompletedAuthorLoad
|
||||||
|
, fetchFeed session defaultFeedTab username 1
|
||||||
|
, Task.perform GotTimeZone Time.here
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
currentUsername : Model -> Username
|
||||||
|
currentUsername model =
|
||||||
|
case model.author of
|
||||||
|
Loading username ->
|
||||||
|
username
|
||||||
|
|
||||||
|
LoadingSlowly username ->
|
||||||
|
username
|
||||||
|
|
||||||
|
Loaded author ->
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
Failed username ->
|
||||||
|
username
|
||||||
|
|
||||||
|
|
||||||
|
defaultFeedTab : FeedTab
|
||||||
|
defaultFeedTab =
|
||||||
|
MyArticles
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
fetchFeed : Session -> FeedTab -> Username -> Int -> Cmd Msg
|
||||||
|
fetchFeed session feedTabs username page =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
( extraParamName, extraParamVal ) =
|
||||||
|
case feedTabs of
|
||||||
|
MyArticles ->
|
||||||
|
( "author", Username.toString username )
|
||||||
|
|
||||||
|
FavoritedArticles ->
|
||||||
|
( "favorited", Username.toString username )
|
||||||
|
in
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Feed.decoder maybeCred articlesPerPage))
|
||||||
|
|> HttpBuilder.withQueryParam extraParamName extraParamVal
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> PaginatedList.fromRequestBuilder articlesPerPage page
|
||||||
|
|> Task.map (Feed.init session)
|
||||||
|
|> Task.mapError (Tuple.pair username)
|
||||||
|
|> Task.attempt CompletedFeedLoad
|
||||||
|
|
||||||
|
|
||||||
|
articlesPerPage : Int
|
||||||
|
articlesPerPage =
|
||||||
|
5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
title =
|
||||||
|
case model.author of
|
||||||
|
Loaded (IsViewer _ _) ->
|
||||||
|
myProfileTitle
|
||||||
|
|
||||||
|
Loaded ((IsFollowing followedAuthor) as author) ->
|
||||||
|
titleForOther (Author.username author)
|
||||||
|
|
||||||
|
Loaded ((IsNotFollowing unfollowedAuthor) as author) ->
|
||||||
|
titleForOther (Author.username author)
|
||||||
|
|
||||||
|
Loading username ->
|
||||||
|
titleForMe (Session.cred model.session) username
|
||||||
|
|
||||||
|
LoadingSlowly username ->
|
||||||
|
titleForMe (Session.cred model.session) username
|
||||||
|
|
||||||
|
Failed username ->
|
||||||
|
titleForMe (Session.cred model.session) username
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, content =
|
||||||
|
case model.author of
|
||||||
|
Loaded author ->
|
||||||
|
let
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
username =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
followButton =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
case author of
|
||||||
|
IsViewer _ _ ->
|
||||||
|
-- We can't follow ourselves!
|
||||||
|
text ""
|
||||||
|
|
||||||
|
IsFollowing followedAuthor ->
|
||||||
|
Author.unfollowButton ClickedUnfollow cred followedAuthor
|
||||||
|
|
||||||
|
IsNotFollowing unfollowedAuthor ->
|
||||||
|
Author.followButton ClickedFollow cred unfollowedAuthor
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
-- We can't follow if we're logged out
|
||||||
|
text ""
|
||||||
|
in
|
||||||
|
div [ class "profile-page" ]
|
||||||
|
[ Page.viewErrors ClickedDismissErrors model.errors
|
||||||
|
, div [ class "user-info" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-10 offset-md-1" ]
|
||||||
|
[ img [ class "user-img", Avatar.src (Profile.avatar profile) ] []
|
||||||
|
, h4 [] [ Username.toHtml username ]
|
||||||
|
, p [] [ text (Maybe.withDefault "" (Profile.bio profile)) ]
|
||||||
|
, followButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
, case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
div [ class "container" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-10 offset-md-1" ]
|
||||||
|
[ div [ class "articles-toggle" ] <|
|
||||||
|
List.concat
|
||||||
|
[ [ viewTabs model.feedTab ]
|
||||||
|
, Feed.viewArticles model.timeZone feed
|
||||||
|
|> List.map (Html.map GotFeedMsg)
|
||||||
|
, [ Feed.viewPagination ClickedFeedPage feed ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
Loading.icon
|
||||||
|
|
||||||
|
Failed _ ->
|
||||||
|
Loading.error "feed"
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
text ""
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
Loading.icon
|
||||||
|
|
||||||
|
Failed _ ->
|
||||||
|
Loading.error "profile"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PAGE TITLE
|
||||||
|
|
||||||
|
|
||||||
|
titleForOther : Username -> String
|
||||||
|
titleForOther otherUsername =
|
||||||
|
"Profile — " ++ Username.toString otherUsername
|
||||||
|
|
||||||
|
|
||||||
|
titleForMe : Maybe Cred -> Username -> String
|
||||||
|
titleForMe maybeCred username =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
if username == Cred.username cred then
|
||||||
|
myProfileTitle
|
||||||
|
|
||||||
|
else
|
||||||
|
defaultTitle
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
defaultTitle
|
||||||
|
|
||||||
|
|
||||||
|
myProfileTitle : String
|
||||||
|
myProfileTitle =
|
||||||
|
"My Profile"
|
||||||
|
|
||||||
|
|
||||||
|
defaultTitle : String
|
||||||
|
defaultTitle =
|
||||||
|
"Profile"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TABS
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs : FeedTab -> Html Msg
|
||||||
|
viewTabs tab =
|
||||||
|
case tab of
|
||||||
|
MyArticles ->
|
||||||
|
Feed.viewTabs [] myArticles [ favoritedArticles ]
|
||||||
|
|
||||||
|
FavoritedArticles ->
|
||||||
|
Feed.viewTabs [ myArticles ] favoritedArticles []
|
||||||
|
|
||||||
|
|
||||||
|
myArticles : ( String, Msg )
|
||||||
|
myArticles =
|
||||||
|
( "My Articles", ClickedTab MyArticles )
|
||||||
|
|
||||||
|
|
||||||
|
favoritedArticles : ( String, Msg )
|
||||||
|
favoritedArticles =
|
||||||
|
( "Favorited Articles", ClickedTab FavoritedArticles )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDismissErrors
|
||||||
|
| ClickedFollow Cred UnfollowedAuthor
|
||||||
|
| ClickedUnfollow Cred FollowedAuthor
|
||||||
|
| ClickedTab FeedTab
|
||||||
|
| ClickedFeedPage Int
|
||||||
|
| CompletedFollowChange (Result Http.Error Author)
|
||||||
|
| CompletedAuthorLoad (Result ( Username, Http.Error ) Author)
|
||||||
|
| CompletedFeedLoad (Result ( Username, Http.Error ) Feed.Model)
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotFeedMsg Feed.Msg
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedUnfollow cred followedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestUnfollow followedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFollow cred unfollowedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestFollow unfollowedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedTab tab ->
|
||||||
|
( { model | feedTab = tab }
|
||||||
|
, fetchFeed model.session tab (currentUsername model) 1
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFeedPage page ->
|
||||||
|
( { model | feedPage = page }
|
||||||
|
, fetchFeed model.session model.feedTab (currentUsername model) page
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Ok newAuthor) ->
|
||||||
|
( { model | author = Loaded newAuthor }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Err error) ->
|
||||||
|
( model
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedAuthorLoad (Ok author) ->
|
||||||
|
( { model | author = Loaded author }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedAuthorLoad (Err ( username, err )) ->
|
||||||
|
( { model | author = Failed username }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFeedLoad (Ok feed) ->
|
||||||
|
( { model | feed = Loaded feed }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFeedLoad (Err ( username, err )) ->
|
||||||
|
( { model | feed = Failed username }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotFeedMsg subMsg ->
|
||||||
|
case model.feed of
|
||||||
|
Loaded feed ->
|
||||||
|
let
|
||||||
|
( newFeed, subCmd ) =
|
||||||
|
Feed.update (Session.cred model.session) subMsg feed
|
||||||
|
in
|
||||||
|
( { model | feed = Loaded newFeed }
|
||||||
|
, Cmd.map GotFeedMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
Loading _ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
Failed _ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
feed =
|
||||||
|
case model.feed of
|
||||||
|
Loading username ->
|
||||||
|
LoadingSlowly username
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | feed = feed }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
module Page.Register exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (..)
|
||||||
|
import Html.Events exposing (..)
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, problems : List Problem
|
||||||
|
, form : Form
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ email : String
|
||||||
|
, username : String
|
||||||
|
, password : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd msg )
|
||||||
|
init session =
|
||||||
|
( { session = session
|
||||||
|
, problems = []
|
||||||
|
, form =
|
||||||
|
{ email = ""
|
||||||
|
, username = ""
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Register"
|
||||||
|
, content =
|
||||||
|
div [ class "cred-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-6 offset-md-3 col-xs-12" ]
|
||||||
|
[ h1 [ class "text-xs-center" ] [ text "Sign up" ]
|
||||||
|
, p [ class "text-xs-center" ]
|
||||||
|
[ a [ Route.href Route.Login ]
|
||||||
|
[ text "Have an account?" ]
|
||||||
|
]
|
||||||
|
, ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem model.problems)
|
||||||
|
, viewForm model.form
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Form -> Html Msg
|
||||||
|
viewForm form =
|
||||||
|
Html.form [ onSubmit SubmittedForm ]
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Username"
|
||||||
|
, onInput EnteredUsername
|
||||||
|
, value form.username
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Email"
|
||||||
|
, onInput EnteredEmail
|
||||||
|
, value form.email
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, type_ "password"
|
||||||
|
, placeholder "Password"
|
||||||
|
, onInput EnteredPassword
|
||||||
|
, value form.password
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button [ class "btn btn-lg btn-primary pull-xs-right" ]
|
||||||
|
[ text "Sign up" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ str ->
|
||||||
|
str
|
||||||
|
|
||||||
|
ServerError str ->
|
||||||
|
str
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SubmittedForm
|
||||||
|
| EnteredEmail String
|
||||||
|
| EnteredUsername String
|
||||||
|
| EnteredPassword String
|
||||||
|
| CompletedRegister (Result Http.Error Viewer)
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
SubmittedForm ->
|
||||||
|
case validate model.form of
|
||||||
|
Ok validForm ->
|
||||||
|
( { model | problems = [] }
|
||||||
|
, Http.send CompletedRegister (register validForm)
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( { model | problems = problems }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredUsername username ->
|
||||||
|
updateForm (\form -> { form | username = username }) model
|
||||||
|
|
||||||
|
EnteredEmail email ->
|
||||||
|
updateForm (\form -> { form | email = email }) model
|
||||||
|
|
||||||
|
EnteredPassword password ->
|
||||||
|
updateForm (\form -> { form | password = password }) model
|
||||||
|
|
||||||
|
CompletedRegister (Err error) ->
|
||||||
|
let
|
||||||
|
serverErrors =
|
||||||
|
Api.decodeErrors error
|
||||||
|
|> List.map ServerError
|
||||||
|
in
|
||||||
|
( { model | problems = List.append model.problems serverErrors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedRegister (Ok viewer) ->
|
||||||
|
( model
|
||||||
|
, Session.login viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form and returns Cmd.none and
|
||||||
|
Ignored. Useful for recording form fields!
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
( { model | form = transform model.form }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Username
|
||||||
|
| Email
|
||||||
|
| Password
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Username
|
||||||
|
, Email
|
||||||
|
, Password
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Username ->
|
||||||
|
if String.isEmpty form.username then
|
||||||
|
[ "username can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Email ->
|
||||||
|
if String.isEmpty form.email then
|
||||||
|
[ "email can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Password ->
|
||||||
|
if String.isEmpty form.password then
|
||||||
|
[ "password can't be blank." ]
|
||||||
|
|
||||||
|
else if String.length form.password < Viewer.minPasswordChars then
|
||||||
|
[ "password must be at least " ++ String.fromInt Viewer.minPasswordChars ++ " characters long." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ username = String.trim form.username
|
||||||
|
, email = String.trim form.email
|
||||||
|
, password = String.trim form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
register : TrimmedForm -> Http.Request Viewer
|
||||||
|
register (Trimmed form) =
|
||||||
|
let
|
||||||
|
user =
|
||||||
|
Encode.object
|
||||||
|
[ ( "username", Encode.string form.username )
|
||||||
|
, ( "email", Encode.string form.email )
|
||||||
|
, ( "password", Encode.string form.password )
|
||||||
|
]
|
||||||
|
|
||||||
|
body =
|
||||||
|
Encode.object [ ( "user", user ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Decode.field "user" Viewer.decoder
|
||||||
|
|> Http.post (Api.url [ "users" ]) body
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
module Page.Settings exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Avatar
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Email exposing (Email)
|
||||||
|
import Html exposing (Html, button, div, fieldset, h1, input, li, text, textarea, ul)
|
||||||
|
import Html.Attributes exposing (attribute, class, placeholder, type_, value)
|
||||||
|
import Html.Events exposing (onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, list, string)
|
||||||
|
import Json.Decode.Pipeline exposing (optional)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Username as Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, problems : List Problem
|
||||||
|
, form : Form
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ avatar : String
|
||||||
|
, bio : String
|
||||||
|
, email : String
|
||||||
|
, username : String
|
||||||
|
, password : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> ( Model, Cmd msg )
|
||||||
|
init session =
|
||||||
|
( { session = session
|
||||||
|
, problems = []
|
||||||
|
, form =
|
||||||
|
case Session.viewer session of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
profile =
|
||||||
|
Viewer.profile viewer
|
||||||
|
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
in
|
||||||
|
{ avatar = Maybe.withDefault "" (Avatar.toMaybeString (Profile.avatar profile))
|
||||||
|
, email = Email.toString (Viewer.email viewer)
|
||||||
|
, bio = Maybe.withDefault "" (Profile.bio profile)
|
||||||
|
, username = Username.toString (Cred.username cred)
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
-- It's fine to store a blank form here. You won't be
|
||||||
|
-- able to submit it if you're not logged in anyway.
|
||||||
|
{ avatar = ""
|
||||||
|
, email = ""
|
||||||
|
, bio = ""
|
||||||
|
, username = ""
|
||||||
|
, password = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| A form that has been validated. Only the `edit` function uses this. Its
|
||||||
|
purpose is to prevent us from forgetting to validate the form before passing
|
||||||
|
it to `edit`.
|
||||||
|
|
||||||
|
This doesn't create any guarantees that the form was actually validated. If
|
||||||
|
we wanted to do that, we'd need to move the form data into a separate module!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type ValidForm
|
||||||
|
= Valid Form
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title = "Settings"
|
||||||
|
, content =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
div [ class "settings-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-6 offset-md-3 col-xs-12" ]
|
||||||
|
[ h1 [ class "text-xs-center" ] [ text "Your Settings" ]
|
||||||
|
, ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem model.problems)
|
||||||
|
, viewForm cred model.form
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text "Sign in to view your settings."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Cred -> Form -> Html Msg
|
||||||
|
viewForm cred form =
|
||||||
|
Html.form [ onSubmit (SubmittedForm cred) ]
|
||||||
|
[ fieldset []
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "URL of profile picture"
|
||||||
|
, value form.avatar
|
||||||
|
, onInput EnteredAvatar
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Username"
|
||||||
|
, value form.username
|
||||||
|
, onInput EnteredUsername
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Short bio about you"
|
||||||
|
, attribute "rows" "8"
|
||||||
|
, value form.bio
|
||||||
|
, onInput EnteredBio
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Email"
|
||||||
|
, value form.email
|
||||||
|
, onInput EnteredEmail
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, type_ "password"
|
||||||
|
, placeholder "Password"
|
||||||
|
, value form.password
|
||||||
|
, onInput EnteredPassword
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, button
|
||||||
|
[ class "btn btn-lg btn-primary pull-xs-right" ]
|
||||||
|
[ text "Update Settings" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ message ->
|
||||||
|
message
|
||||||
|
|
||||||
|
ServerError message ->
|
||||||
|
message
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= SubmittedForm Cred
|
||||||
|
| EnteredEmail String
|
||||||
|
| EnteredUsername String
|
||||||
|
| EnteredPassword String
|
||||||
|
| EnteredBio String
|
||||||
|
| EnteredAvatar String
|
||||||
|
| CompletedSave (Result Http.Error Viewer)
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
SubmittedForm cred ->
|
||||||
|
case validate model.form of
|
||||||
|
Ok validForm ->
|
||||||
|
( { model | problems = [] }
|
||||||
|
, edit cred validForm
|
||||||
|
|> Http.send CompletedSave
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( { model | problems = problems }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredEmail email ->
|
||||||
|
updateForm (\form -> { form | email = email }) model
|
||||||
|
|
||||||
|
EnteredUsername username ->
|
||||||
|
updateForm (\form -> { form | username = username }) model
|
||||||
|
|
||||||
|
EnteredPassword password ->
|
||||||
|
updateForm (\form -> { form | password = password }) model
|
||||||
|
|
||||||
|
EnteredBio bio ->
|
||||||
|
updateForm (\form -> { form | bio = bio }) model
|
||||||
|
|
||||||
|
EnteredAvatar avatar ->
|
||||||
|
updateForm (\form -> { form | avatar = avatar }) model
|
||||||
|
|
||||||
|
CompletedSave (Err error) ->
|
||||||
|
let
|
||||||
|
serverErrors =
|
||||||
|
Api.decodeErrors error
|
||||||
|
|> List.map ServerError
|
||||||
|
in
|
||||||
|
( { model | problems = List.append model.problems serverErrors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedSave (Ok cred) ->
|
||||||
|
( model
|
||||||
|
, Session.login cred
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form and returns Cmd.none and
|
||||||
|
Ignored. Useful for recording form fields!
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
( { model | form = transform model.form }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
|
||||||
|
NOTE: there are no ImageUrl or Bio variants here, because they aren't validated!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Username
|
||||||
|
| Email
|
||||||
|
| Password
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Username
|
||||||
|
, Email
|
||||||
|
, Password
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Username ->
|
||||||
|
if String.isEmpty form.username then
|
||||||
|
[ "username can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Email ->
|
||||||
|
if String.isEmpty form.email then
|
||||||
|
[ "email can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Password ->
|
||||||
|
let
|
||||||
|
passwordLength =
|
||||||
|
String.length form.password
|
||||||
|
in
|
||||||
|
if passwordLength > 0 && passwordLength < Viewer.minPasswordChars then
|
||||||
|
[ "password must be at least " ++ String.fromInt Viewer.minPasswordChars ++ " characters long." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ avatar = String.trim form.avatar
|
||||||
|
, bio = String.trim form.bio
|
||||||
|
, email = String.trim form.email
|
||||||
|
, username = String.trim form.username
|
||||||
|
, password = String.trim form.password
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
{-| This takes a Valid Form as a reminder that it needs to have been validated
|
||||||
|
first.
|
||||||
|
-}
|
||||||
|
edit : Cred -> TrimmedForm -> Http.Request Viewer
|
||||||
|
edit cred (Trimmed form) =
|
||||||
|
let
|
||||||
|
encodedAvatar =
|
||||||
|
case form.avatar of
|
||||||
|
"" ->
|
||||||
|
Encode.null
|
||||||
|
|
||||||
|
avatar ->
|
||||||
|
Encode.string avatar
|
||||||
|
|
||||||
|
updates =
|
||||||
|
[ ( "username", Encode.string form.username )
|
||||||
|
, ( "email", Encode.string form.email )
|
||||||
|
, ( "bio", Encode.string form.bio )
|
||||||
|
, ( "image", encodedAvatar )
|
||||||
|
]
|
||||||
|
|
||||||
|
encodedUser =
|
||||||
|
Encode.object <|
|
||||||
|
case form.password of
|
||||||
|
"" ->
|
||||||
|
updates
|
||||||
|
|
||||||
|
password ->
|
||||||
|
( "password", Encode.string password ) :: updates
|
||||||
|
|
||||||
|
body =
|
||||||
|
Encode.object [ ( "user", encodedUser ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
|
||||||
|
expect =
|
||||||
|
Decode.field "user" Viewer.decoder
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
Api.url [ "user" ]
|
||||||
|
|> HttpBuilder.put
|
||||||
|
|> HttpBuilder.withExpect expect
|
||||||
|
|> HttpBuilder.withBody body
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
nothingIfEmpty : String -> Maybe String
|
||||||
|
nothingIfEmpty str =
|
||||||
|
if String.isEmpty str then
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
else
|
||||||
|
Just str
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
module PaginatedList exposing (PaginatedList, fromList, fromRequestBuilder, map, page, total, values, view)
|
||||||
|
|
||||||
|
import Html exposing (Html, a, li, text, ul)
|
||||||
|
import Html.Attributes exposing (class, classList, href)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Task exposing (Task)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type PaginatedList a
|
||||||
|
= PaginatedList
|
||||||
|
{ values : List a
|
||||||
|
, total : Int
|
||||||
|
, page : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
values : PaginatedList a -> List a
|
||||||
|
values (PaginatedList info) =
|
||||||
|
info.values
|
||||||
|
|
||||||
|
|
||||||
|
total : PaginatedList a -> Int
|
||||||
|
total (PaginatedList info) =
|
||||||
|
info.total
|
||||||
|
|
||||||
|
|
||||||
|
page : PaginatedList a -> Int
|
||||||
|
page (PaginatedList info) =
|
||||||
|
info.page
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
fromList : Int -> List a -> PaginatedList a
|
||||||
|
fromList totalCount list =
|
||||||
|
PaginatedList { values = list, total = totalCount, page = 1 }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
map : (a -> a) -> PaginatedList a -> PaginatedList a
|
||||||
|
map transform (PaginatedList info) =
|
||||||
|
PaginatedList { info | values = List.map transform info.values }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : (Int -> msg) -> PaginatedList a -> Html msg
|
||||||
|
view toMsg (PaginatedList info) =
|
||||||
|
let
|
||||||
|
viewPageLink currentPage =
|
||||||
|
pageLink toMsg currentPage (currentPage == info.page)
|
||||||
|
in
|
||||||
|
if info.total > 1 then
|
||||||
|
List.range 1 info.total
|
||||||
|
|> List.map viewPageLink
|
||||||
|
|> ul [ class "pagination" ]
|
||||||
|
|
||||||
|
else
|
||||||
|
Html.text ""
|
||||||
|
|
||||||
|
|
||||||
|
pageLink : (Int -> msg) -> Int -> Bool -> Html msg
|
||||||
|
pageLink toMsg targetPage isActive =
|
||||||
|
li [ classList [ ( "page-item", True ), ( "active", isActive ) ] ]
|
||||||
|
[ a
|
||||||
|
[ class "page-link"
|
||||||
|
, onClick (toMsg targetPage)
|
||||||
|
|
||||||
|
-- The RealWorld CSS requires an href to work properly.
|
||||||
|
, href ""
|
||||||
|
]
|
||||||
|
[ text (String.fromInt targetPage) ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
{-| I considered accepting a record here so I don't mess up the argument order.
|
||||||
|
-}
|
||||||
|
fromRequestBuilder :
|
||||||
|
Int
|
||||||
|
-> Int
|
||||||
|
-> RequestBuilder (PaginatedList a)
|
||||||
|
-> Task Http.Error (PaginatedList a)
|
||||||
|
fromRequestBuilder resultsPerPage pageNumber builder =
|
||||||
|
let
|
||||||
|
offset =
|
||||||
|
(pageNumber - 1) * resultsPerPage
|
||||||
|
|
||||||
|
params =
|
||||||
|
[ ( "limit", String.fromInt resultsPerPage )
|
||||||
|
, ( "offset", String.fromInt offset )
|
||||||
|
]
|
||||||
|
in
|
||||||
|
builder
|
||||||
|
|> HttpBuilder.withQueryParams params
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.map (\(PaginatedList info) -> PaginatedList { info | page = pageNumber })
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
module Profile exposing (Profile, avatar, bio, decoder)
|
||||||
|
|
||||||
|
{-| A user's profile - potentially your own!
|
||||||
|
|
||||||
|
Contrast with Cred, which is the currently signed-in user.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Profile
|
||||||
|
= Profile Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ bio : Maybe String
|
||||||
|
, avatar : Avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
bio : Profile -> Maybe String
|
||||||
|
bio (Profile info) =
|
||||||
|
info.bio
|
||||||
|
|
||||||
|
|
||||||
|
avatar : Profile -> Avatar
|
||||||
|
avatar (Profile info) =
|
||||||
|
info.avatar
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Profile
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "bio" (Decode.nullable Decode.string)
|
||||||
|
|> required "image" Avatar.decoder
|
||||||
|
|> Decode.map Profile
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
module Route exposing (Route(..), fromUrl, href, replaceUrl)
|
||||||
|
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ROUTING
|
||||||
|
|
||||||
|
|
||||||
|
type Route
|
||||||
|
= Home
|
||||||
|
| Root
|
||||||
|
| Login
|
||||||
|
| Logout
|
||||||
|
| Register
|
||||||
|
| Settings
|
||||||
|
| Article Slug
|
||||||
|
| Profile Username
|
||||||
|
| NewArticle
|
||||||
|
| EditArticle Slug
|
||||||
|
|
||||||
|
|
||||||
|
parser : Parser (Route -> a) a
|
||||||
|
parser =
|
||||||
|
oneOf
|
||||||
|
[ Parser.map Home Parser.top
|
||||||
|
, Parser.map Login (s "login")
|
||||||
|
, Parser.map Logout (s "logout")
|
||||||
|
, Parser.map Settings (s "settings")
|
||||||
|
, Parser.map Profile (s "profile" </> Username.urlParser)
|
||||||
|
, Parser.map Register (s "register")
|
||||||
|
, Parser.map Article (s "article" </> Slug.urlParser)
|
||||||
|
, Parser.map NewArticle (s "editor")
|
||||||
|
, Parser.map EditArticle (s "editor" </> Slug.urlParser)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- PUBLIC HELPERS
|
||||||
|
|
||||||
|
|
||||||
|
href : Route -> Attribute msg
|
||||||
|
href targetRoute =
|
||||||
|
Attr.href (routeToString targetRoute)
|
||||||
|
|
||||||
|
|
||||||
|
replaceUrl : Nav.Key -> Route -> Cmd msg
|
||||||
|
replaceUrl key route =
|
||||||
|
Nav.replaceUrl key (routeToString route)
|
||||||
|
|
||||||
|
|
||||||
|
fromUrl : Url -> Maybe Route
|
||||||
|
fromUrl url =
|
||||||
|
-- The RealWorld spec treats the fragment like a path.
|
||||||
|
-- This makes it *literally* the path, so we can proceed
|
||||||
|
-- with parsing as if it had been a normal path all along.
|
||||||
|
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|
||||||
|
|> Parser.parse parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
routeToString : Route -> String
|
||||||
|
routeToString page =
|
||||||
|
let
|
||||||
|
pieces =
|
||||||
|
case page of
|
||||||
|
Home ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Root ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
Login ->
|
||||||
|
[ "login" ]
|
||||||
|
|
||||||
|
Logout ->
|
||||||
|
[ "logout" ]
|
||||||
|
|
||||||
|
Register ->
|
||||||
|
[ "register" ]
|
||||||
|
|
||||||
|
Settings ->
|
||||||
|
[ "settings" ]
|
||||||
|
|
||||||
|
Article slug ->
|
||||||
|
[ "article", Slug.toString slug ]
|
||||||
|
|
||||||
|
Profile username ->
|
||||||
|
[ "profile", Username.toString username ]
|
||||||
|
|
||||||
|
NewArticle ->
|
||||||
|
[ "editor" ]
|
||||||
|
|
||||||
|
EditArticle slug ->
|
||||||
|
[ "editor", Slug.toString slug ]
|
||||||
|
in
|
||||||
|
"#/" ++ String.join "/" pieces
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
port module Session
|
||||||
|
exposing
|
||||||
|
( Session
|
||||||
|
, changes
|
||||||
|
, cred
|
||||||
|
, decode
|
||||||
|
, login
|
||||||
|
, logout
|
||||||
|
, navKey
|
||||||
|
, viewer
|
||||||
|
)
|
||||||
|
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Session
|
||||||
|
= LoggedIn Nav.Key Viewer
|
||||||
|
| Guest Nav.Key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
viewer : Session -> Maybe Viewer
|
||||||
|
viewer session =
|
||||||
|
case session of
|
||||||
|
LoggedIn _ val ->
|
||||||
|
Just val
|
||||||
|
|
||||||
|
Guest _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
cred : Session -> Maybe Cred
|
||||||
|
cred session =
|
||||||
|
case session of
|
||||||
|
LoggedIn _ val ->
|
||||||
|
Just (Viewer.cred val)
|
||||||
|
|
||||||
|
Guest _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
|
||||||
|
navKey : Session -> Nav.Key
|
||||||
|
navKey session =
|
||||||
|
case session of
|
||||||
|
LoggedIn key _ ->
|
||||||
|
key
|
||||||
|
|
||||||
|
Guest key ->
|
||||||
|
key
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LOGIN
|
||||||
|
|
||||||
|
|
||||||
|
login : Viewer -> Cmd msg
|
||||||
|
login newViewer =
|
||||||
|
Viewer.encode newViewer
|
||||||
|
|> Encode.encode 0
|
||||||
|
|> Just
|
||||||
|
|> storeSession
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LOGOUT
|
||||||
|
|
||||||
|
|
||||||
|
logout : Cmd msg
|
||||||
|
logout =
|
||||||
|
storeSession Nothing
|
||||||
|
|
||||||
|
|
||||||
|
port storeSession : Maybe String -> Cmd msg
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CHANGES
|
||||||
|
|
||||||
|
|
||||||
|
changes : (Session -> msg) -> Nav.Key -> Sub msg
|
||||||
|
changes toMsg key =
|
||||||
|
onSessionChange (\val -> toMsg (decode key val))
|
||||||
|
|
||||||
|
|
||||||
|
port onSessionChange : (Value -> msg) -> Sub msg
|
||||||
|
|
||||||
|
|
||||||
|
decode : Nav.Key -> Value -> Session
|
||||||
|
decode key value =
|
||||||
|
-- It's stored in localStorage as a JSON String;
|
||||||
|
-- first decode the Value as a String, then
|
||||||
|
-- decode that String as JSON.
|
||||||
|
case
|
||||||
|
Decode.decodeValue Decode.string value
|
||||||
|
|> Result.andThen (Decode.decodeString Viewer.decoder)
|
||||||
|
|> Result.toMaybe
|
||||||
|
of
|
||||||
|
Just decodedViewer ->
|
||||||
|
LoggedIn key decodedViewer
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Guest key
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
module Timestamp exposing (format, iso8601Decoder, view)
|
||||||
|
|
||||||
|
import Html exposing (Html, span, text)
|
||||||
|
import Html.Attributes exposing (class)
|
||||||
|
import Iso8601
|
||||||
|
import Json.Decode as Decode exposing (Decoder, fail, succeed)
|
||||||
|
import Time exposing (Month(..))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Time.Zone -> Time.Posix -> Html msg
|
||||||
|
view timeZone timestamp =
|
||||||
|
span [ class "date" ] [ text (format timeZone timestamp) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- DECODE
|
||||||
|
|
||||||
|
|
||||||
|
{-| Decode an ISO-8601 date string.
|
||||||
|
-}
|
||||||
|
iso8601Decoder : Decoder Time.Posix
|
||||||
|
iso8601Decoder =
|
||||||
|
Decode.string
|
||||||
|
|> Decode.andThen fromString
|
||||||
|
|
||||||
|
|
||||||
|
fromString : String -> Decoder Time.Posix
|
||||||
|
fromString str =
|
||||||
|
case Iso8601.toTime str of
|
||||||
|
Ok successValue ->
|
||||||
|
succeed successValue
|
||||||
|
|
||||||
|
Err _ ->
|
||||||
|
fail ("Invalid date: " ++ str)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORMAT
|
||||||
|
|
||||||
|
|
||||||
|
{-| Format a timestamp as a String, like so:
|
||||||
|
|
||||||
|
"February 14, 2018"
|
||||||
|
|
||||||
|
For more complex date formatting scenarios, here's a nice package:
|
||||||
|
<https://package.elm-lang.org/packages/ryannhg/date-format/latest/>
|
||||||
|
|
||||||
|
-}
|
||||||
|
format : Time.Zone -> Time.Posix -> String
|
||||||
|
format zone time =
|
||||||
|
let
|
||||||
|
month =
|
||||||
|
case Time.toMonth zone time of
|
||||||
|
Jan ->
|
||||||
|
"January"
|
||||||
|
|
||||||
|
Feb ->
|
||||||
|
"February"
|
||||||
|
|
||||||
|
Mar ->
|
||||||
|
"March"
|
||||||
|
|
||||||
|
Apr ->
|
||||||
|
"April"
|
||||||
|
|
||||||
|
May ->
|
||||||
|
"May"
|
||||||
|
|
||||||
|
Jun ->
|
||||||
|
"June"
|
||||||
|
|
||||||
|
Jul ->
|
||||||
|
"July"
|
||||||
|
|
||||||
|
Aug ->
|
||||||
|
"August"
|
||||||
|
|
||||||
|
Sep ->
|
||||||
|
"September"
|
||||||
|
|
||||||
|
Oct ->
|
||||||
|
"October"
|
||||||
|
|
||||||
|
Nov ->
|
||||||
|
"November"
|
||||||
|
|
||||||
|
Dec ->
|
||||||
|
"December"
|
||||||
|
|
||||||
|
day =
|
||||||
|
String.fromInt (Time.toDay zone time)
|
||||||
|
|
||||||
|
year =
|
||||||
|
String.fromInt (Time.toYear zone time)
|
||||||
|
in
|
||||||
|
month ++ " " ++ day ++ ", " ++ year
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Username exposing (Username, decoder, encode, toHtml, toString, urlParser)
|
||||||
|
|
||||||
|
import Html exposing (Html)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Url.Parser
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Username
|
||||||
|
= Username String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Username
|
||||||
|
decoder =
|
||||||
|
Decode.map Username Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Username -> Value
|
||||||
|
encode (Username username) =
|
||||||
|
Encode.string username
|
||||||
|
|
||||||
|
|
||||||
|
toString : Username -> String
|
||||||
|
toString (Username username) =
|
||||||
|
username
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Url.Parser.Parser (Username -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "USERNAME" (\str -> Just (Username str))
|
||||||
|
|
||||||
|
|
||||||
|
toHtml : Username -> Html msg
|
||||||
|
toHtml (Username username) =
|
||||||
|
Html.text username
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
module Viewer exposing (Viewer, cred, decoder, email, encode, minPasswordChars, profile)
|
||||||
|
|
||||||
|
{-| The logged-in user currently viewing this page.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Email exposing (Email)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Viewer
|
||||||
|
= Viewer Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ cred : Cred
|
||||||
|
, profile : Profile
|
||||||
|
, email : Email
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
cred : Viewer -> Cred
|
||||||
|
cred (Viewer info) =
|
||||||
|
info.cred
|
||||||
|
|
||||||
|
|
||||||
|
profile : Viewer -> Profile
|
||||||
|
profile (Viewer info) =
|
||||||
|
info.profile
|
||||||
|
|
||||||
|
|
||||||
|
email : Viewer -> Email
|
||||||
|
email (Viewer info) =
|
||||||
|
info.email
|
||||||
|
|
||||||
|
|
||||||
|
{-| Passwords must be at least this many characters long!
|
||||||
|
-}
|
||||||
|
minPasswordChars : Int
|
||||||
|
minPasswordChars =
|
||||||
|
6
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
encode : Viewer -> Value
|
||||||
|
encode (Viewer info) =
|
||||||
|
Encode.object
|
||||||
|
[ ( "email", Email.encode info.email )
|
||||||
|
, ( "username", Username.encode (Cred.username info.cred) )
|
||||||
|
, ( "image", Avatar.encode (Profile.avatar info.profile) )
|
||||||
|
, ( "token", Cred.encodeToken info.cred )
|
||||||
|
, ( "bio"
|
||||||
|
, case Profile.bio info.profile of
|
||||||
|
Just bio ->
|
||||||
|
Encode.string bio
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Encode.null
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Viewer
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> custom Cred.decoder
|
||||||
|
|> custom Profile.decoder
|
||||||
|
|> required "email" Email.decoder
|
||||||
|
|> Decode.map Viewer
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
module Viewer.Cred exposing (Cred, addHeader, addHeaderIfAvailable, decoder, encodeToken, username)
|
||||||
|
|
||||||
|
{-| The authentication credentials for the Viewer (that is, the currently logged-in user.)
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
- The cred's Username
|
||||||
|
- The cred's authentication token
|
||||||
|
|
||||||
|
By design, there is no way to access the token directly as a String.
|
||||||
|
It can be encoded for persistence, and it can be added to a header
|
||||||
|
to a HttpBuilder for a request, but that's it.
|
||||||
|
|
||||||
|
This token should never be rendered to the end user, and with this API, it
|
||||||
|
can't be!
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withHeader)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Username exposing (Username)
|
||||||
|
|
||||||
|
|
||||||
|
{-| The authentication token for the currently logged-in user.
|
||||||
|
|
||||||
|
The token records the username associated with this token, which you can ask it for.
|
||||||
|
|
||||||
|
By design, there is no way to access the token directly as a String. You can encode it for persistence, and you can add it to a header to a HttpBuilder for a request, but that's it.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Cred
|
||||||
|
= Cred Username String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
username : Cred -> Username
|
||||||
|
username (Cred val _) =
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Cred
|
||||||
|
decoder =
|
||||||
|
Decode.succeed Cred
|
||||||
|
|> required "username" Username.decoder
|
||||||
|
|> required "token" Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encodeToken : Cred -> Value
|
||||||
|
encodeToken (Cred _ str) =
|
||||||
|
Encode.string str
|
||||||
|
|
||||||
|
|
||||||
|
addHeader : Cred -> RequestBuilder a -> RequestBuilder a
|
||||||
|
addHeader (Cred _ str) builder =
|
||||||
|
builder
|
||||||
|
|> withHeader "authorization" ("Token " ++ str)
|
||||||
|
|
||||||
|
|
||||||
|
addHeaderIfAvailable : Maybe Cred -> RequestBuilder a -> RequestBuilder a
|
||||||
|
addHeaderIfAvailable maybeCred builder =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
addHeader cred builder
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
builder
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Part 3
|
||||||
|
|
||||||
|
To build everything, `cd` into this `part3/` directory and run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
elm make src/Main.elm --output=../server/public/elm.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
## Exercise
|
||||||
|
|
||||||
|
Open `src/Page/Article.elm` in your editor and resolve the TODOs there.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"type": "application",
|
||||||
|
"source-directories": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"elm-version": "0.19.0",
|
||||||
|
"dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"NoRedInk/elm-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-date-strings": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/parser": "1.0.0",
|
||||||
|
"elm/regex": "1.0.0",
|
||||||
|
"elm/virtual-dom": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test-dependencies": {
|
||||||
|
"direct": {
|
||||||
|
"elm-explorations/test": "1.0.0"
|
||||||
|
},
|
||||||
|
"indirect": {
|
||||||
|
"elm/random": "1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
module Api exposing (addServerError, decodeErrors, url)
|
||||||
|
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder, decodeString, field, string)
|
||||||
|
import Json.Decode.Pipeline as Pipeline exposing (optional)
|
||||||
|
import Url.Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URL
|
||||||
|
|
||||||
|
|
||||||
|
{-| Get a URL to the Conduit API.
|
||||||
|
-}
|
||||||
|
url : List String -> String
|
||||||
|
url paths =
|
||||||
|
-- NOTE: Url.Builder takes care of percent-encoding special URL characters.
|
||||||
|
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
|
||||||
|
Url.Builder.relative ("api" :: paths) []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- ERRORS
|
||||||
|
|
||||||
|
|
||||||
|
addServerError : List String -> List String
|
||||||
|
addServerError list =
|
||||||
|
"Server error" :: list
|
||||||
|
|
||||||
|
|
||||||
|
{-| Many API endpoints include an "errors" field in their BadStatus responses.
|
||||||
|
-}
|
||||||
|
decodeErrors : Http.Error -> List String
|
||||||
|
decodeErrors error =
|
||||||
|
case error of
|
||||||
|
Http.BadStatus response ->
|
||||||
|
response.body
|
||||||
|
|> decodeString (field "errors" errorsDecoder)
|
||||||
|
|> Result.withDefault [ "Server error" ]
|
||||||
|
|
||||||
|
err ->
|
||||||
|
[ "Server error" ]
|
||||||
|
|
||||||
|
|
||||||
|
errorsDecoder : Decoder (List String)
|
||||||
|
errorsDecoder =
|
||||||
|
Decode.keyValuePairs (Decode.list Decode.string)
|
||||||
|
|> Decode.map (List.concatMap fromPair)
|
||||||
|
|
||||||
|
|
||||||
|
fromPair : ( String, List String ) -> List String
|
||||||
|
fromPair ( field, errors ) =
|
||||||
|
List.map (\error -> field ++ " " ++ error) errors
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
module Article
|
||||||
|
exposing
|
||||||
|
( Article
|
||||||
|
, Full
|
||||||
|
, Preview
|
||||||
|
, author
|
||||||
|
, body
|
||||||
|
, favorite
|
||||||
|
, favoriteButton
|
||||||
|
, fetch
|
||||||
|
, fromPreview
|
||||||
|
, fullDecoder
|
||||||
|
, mapAuthor
|
||||||
|
, metadata
|
||||||
|
, previewDecoder
|
||||||
|
, slug
|
||||||
|
, unfavorite
|
||||||
|
, unfavoriteButton
|
||||||
|
, url
|
||||||
|
)
|
||||||
|
|
||||||
|
{-| The interface to the Article data structure.
|
||||||
|
|
||||||
|
This includes:
|
||||||
|
|
||||||
|
- The Article type itself
|
||||||
|
- Ways to make HTTP requests to retrieve and modify articles
|
||||||
|
- Ways to access information about an article
|
||||||
|
- Converting between various types
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article.Body as Body exposing (Body)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Author exposing (Author)
|
||||||
|
import Html exposing (Attribute, Html, i)
|
||||||
|
import Html.Attributes exposing (class)
|
||||||
|
import Html.Events exposing (stopPropagationOn)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, hardcoded, required)
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Markdown
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Username as Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
{-| An article, optionally with an article body.
|
||||||
|
|
||||||
|
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
|
||||||
|
consider the difference between the "view individual article" page (which
|
||||||
|
renders one article, including its body) and the "article feed" -
|
||||||
|
which displays multiple articles, but without bodies.
|
||||||
|
|
||||||
|
This definition for `Article` means we can write:
|
||||||
|
|
||||||
|
viewArticle : Article Full -> Html msg
|
||||||
|
viewFeed : List (Article Preview) -> Html msg
|
||||||
|
|
||||||
|
This indicates that `viewArticle` requires an article _with a `body` present_,
|
||||||
|
wereas `viewFeed` accepts articles with no bodies. (We could also have written
|
||||||
|
it as `List (Article a)` to specify that feeds can accept either articles that
|
||||||
|
have `body` present or not. Either work, given that feeds do not attempt to
|
||||||
|
read the `body` field from articles.)
|
||||||
|
|
||||||
|
This is an important distinction, because in Request.Article, the `feed`
|
||||||
|
function produces `List (Article Preview)` because the API does not return bodies.
|
||||||
|
Those articles are useful to the feed, but not to the individual article view.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Article a
|
||||||
|
= Article Internals a
|
||||||
|
|
||||||
|
|
||||||
|
{-| Metadata about the article - its title, description, and so on.
|
||||||
|
|
||||||
|
Importantly, this module's public API exposes a way to read this metadata, but
|
||||||
|
not to alter it. This is read-only information!
|
||||||
|
|
||||||
|
If we find ourselves using any particular piece of metadata often,
|
||||||
|
for example `title`, we could expose a convenience function like this:
|
||||||
|
|
||||||
|
Article.title : Article a -> String
|
||||||
|
|
||||||
|
If you like, it's totally reasonable to expose a function like that for every one
|
||||||
|
of these fields!
|
||||||
|
|
||||||
|
(Okay, to be completely honest, exposing one function per field is how I prefer
|
||||||
|
to do it, and that's how I originally wrote this module. However, I'm aware that
|
||||||
|
this code base has become a common reference point for beginners, and I think it
|
||||||
|
is _extremely important_ that slapping some "getters and setters" on a record
|
||||||
|
does not become a habit for anyone who is getting started with Elm. The whole
|
||||||
|
point of making the Article type opaque is to create guarantees through
|
||||||
|
_selectively choosing boundaries_ around it. If you aren't selective about
|
||||||
|
where those boundaries are, and instead expose a "getter and setter" for every
|
||||||
|
field in the record, the result is an API with no more guarantees than if you'd
|
||||||
|
exposed the entire record directly! It is so important to me that beginners not
|
||||||
|
fall into the terrible "getters and setters" trap that I've exposed this
|
||||||
|
Metadata record instead of exposing a single function for each of its fields,
|
||||||
|
as I did originally. This record is not a bad way to do it, by any means,
|
||||||
|
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
|
||||||
|
)
|
||||||
|
|
||||||
|
-}
|
||||||
|
type alias Metadata =
|
||||||
|
{ description : String
|
||||||
|
, title : String
|
||||||
|
, tags : List String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, favorited : Bool
|
||||||
|
, favoritesCount : Int
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ slug : Slug
|
||||||
|
, author : Author
|
||||||
|
, metadata : Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Preview
|
||||||
|
= Preview
|
||||||
|
|
||||||
|
|
||||||
|
type Full
|
||||||
|
= Full Body
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
author : Article a -> Author
|
||||||
|
author (Article internals _) =
|
||||||
|
internals.author
|
||||||
|
|
||||||
|
|
||||||
|
metadata : Article a -> Metadata
|
||||||
|
metadata (Article internals _) =
|
||||||
|
internals.metadata
|
||||||
|
|
||||||
|
|
||||||
|
slug : Article a -> Slug
|
||||||
|
slug (Article internals _) =
|
||||||
|
internals.slug
|
||||||
|
|
||||||
|
|
||||||
|
body : Article Full -> Body
|
||||||
|
body (Article _ (Full extraInfo)) =
|
||||||
|
extraInfo
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is the only way you can transform an existing article:
|
||||||
|
you can change its author (e.g. to follow or unfollow them).
|
||||||
|
All other article data necessarily comes from the server!
|
||||||
|
|
||||||
|
We can tell this for sure by looking at the types of the exposed functions
|
||||||
|
in this module.
|
||||||
|
|
||||||
|
-}
|
||||||
|
mapAuthor : (Author -> Author) -> Article a -> Article a
|
||||||
|
mapAuthor transform (Article info extras) =
|
||||||
|
Article { info | author = transform info.author } extras
|
||||||
|
|
||||||
|
|
||||||
|
fromPreview : Body -> Article Preview -> Article Full
|
||||||
|
fromPreview newBody (Article info Preview) =
|
||||||
|
Article info (Full newBody)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
previewDecoder : Maybe Cred -> Decoder (Article Preview)
|
||||||
|
previewDecoder maybeCred =
|
||||||
|
Decode.succeed Article
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> hardcoded Preview
|
||||||
|
|
||||||
|
|
||||||
|
fullDecoder : Maybe Cred -> Decoder (Article Full)
|
||||||
|
fullDecoder maybeCred =
|
||||||
|
Decode.succeed Article
|
||||||
|
|> custom (internalsDecoder maybeCred)
|
||||||
|
|> required "body" (Decode.map Full Body.decoder)
|
||||||
|
|
||||||
|
|
||||||
|
internalsDecoder : Maybe Cred -> Decoder Internals
|
||||||
|
internalsDecoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "slug" Slug.decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> custom metadataDecoder
|
||||||
|
|
||||||
|
|
||||||
|
metadataDecoder : Decoder Metadata
|
||||||
|
metadataDecoder =
|
||||||
|
Decode.succeed Metadata
|
||||||
|
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|
||||||
|
|> required "title" Decode.string
|
||||||
|
|> required "tagList" (Decode.list Decode.string)
|
||||||
|
|> required "createdAt" Timestamp.iso8601Decoder
|
||||||
|
|> required "favorited" Decode.bool
|
||||||
|
|> required "favoritesCount" Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SINGLE
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Maybe Cred -> Slug -> Http.Request (Article Full)
|
||||||
|
fetch maybeCred articleSlug =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
fullDecoder maybeCred
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
url articleSlug []
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect expect
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FAVORITE
|
||||||
|
|
||||||
|
|
||||||
|
favorite : Slug -> Cred -> Http.Request (Article Preview)
|
||||||
|
favorite articleSlug cred =
|
||||||
|
buildFavorite HttpBuilder.post articleSlug cred
|
||||||
|
|
||||||
|
|
||||||
|
unfavorite : Slug -> Cred -> Http.Request (Article Preview)
|
||||||
|
unfavorite articleSlug cred =
|
||||||
|
buildFavorite HttpBuilder.delete articleSlug cred
|
||||||
|
|
||||||
|
|
||||||
|
buildFavorite :
|
||||||
|
(String -> RequestBuilder a)
|
||||||
|
-> Slug
|
||||||
|
-> Cred
|
||||||
|
-> Http.Request (Article Preview)
|
||||||
|
buildFavorite builderFromUrl articleSlug cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
previewDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
in
|
||||||
|
builderFromUrl (url articleSlug [ "favorite" ])
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
{-| This is a "build your own element" API.
|
||||||
|
|
||||||
|
You pass it some configuration, followed by a `List (Attribute msg)` and a
|
||||||
|
`List (Html msg)`, just like any standard Html element.
|
||||||
|
|
||||||
|
-}
|
||||||
|
favoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
|
||||||
|
favoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
unfavoriteButton : Cred -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
|
||||||
|
unfavoriteButton _ msg attrs kids =
|
||||||
|
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
|
||||||
|
|
||||||
|
|
||||||
|
toggleFavoriteButton :
|
||||||
|
String
|
||||||
|
-> msg
|
||||||
|
-> List (Attribute msg)
|
||||||
|
-> List (Html msg)
|
||||||
|
-> Html msg
|
||||||
|
toggleFavoriteButton classStr msg attrs kids =
|
||||||
|
Html.button
|
||||||
|
(class classStr :: onClickStopPropagation msg :: attrs)
|
||||||
|
(i [ class "ion-heart" ] [] :: kids)
|
||||||
|
|
||||||
|
|
||||||
|
onClickStopPropagation : msg -> Attribute msg
|
||||||
|
onClickStopPropagation msg =
|
||||||
|
stopPropagationOn "click"
|
||||||
|
(Decode.succeed ( msg, True ))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URLS
|
||||||
|
|
||||||
|
|
||||||
|
url : Slug -> List String -> String
|
||||||
|
url articleSlug paths =
|
||||||
|
allArticlesUrl (Slug.toString articleSlug :: paths)
|
||||||
|
|
||||||
|
|
||||||
|
allArticlesUrl : List String -> String
|
||||||
|
allArticlesUrl paths =
|
||||||
|
Api.url ("articles" :: paths)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
module Article.Body exposing (Body, MarkdownString, decoder, toHtml, toMarkdownString)
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Markdown
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Body
|
||||||
|
= Body MarkdownString
|
||||||
|
|
||||||
|
|
||||||
|
{-| Internal use only. I want to remind myself that the string inside Body contains markdown.
|
||||||
|
-}
|
||||||
|
type alias MarkdownString =
|
||||||
|
String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CONVERSIONS
|
||||||
|
|
||||||
|
|
||||||
|
toHtml : Body -> List (Attribute msg) -> Html msg
|
||||||
|
toHtml (Body markdown) attributes =
|
||||||
|
Markdown.toHtml attributes markdown
|
||||||
|
|
||||||
|
|
||||||
|
toMarkdownString : Body -> MarkdownString
|
||||||
|
toMarkdownString (Body markdown) =
|
||||||
|
markdown
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Body
|
||||||
|
decoder =
|
||||||
|
Decode.map Body Decode.string
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
module Article.Comment
|
||||||
|
exposing
|
||||||
|
( Comment
|
||||||
|
, author
|
||||||
|
, body
|
||||||
|
, createdAt
|
||||||
|
, delete
|
||||||
|
, id
|
||||||
|
, list
|
||||||
|
, post
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Author exposing (Author)
|
||||||
|
import CommentId exposing (CommentId)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Comment
|
||||||
|
= Comment Internals
|
||||||
|
|
||||||
|
|
||||||
|
type alias Internals =
|
||||||
|
{ id : CommentId
|
||||||
|
, body : String
|
||||||
|
, createdAt : Time.Posix
|
||||||
|
, author : Author
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
id : Comment -> CommentId
|
||||||
|
id (Comment comment) =
|
||||||
|
comment.id
|
||||||
|
|
||||||
|
|
||||||
|
body : Comment -> String
|
||||||
|
body (Comment comment) =
|
||||||
|
comment.body
|
||||||
|
|
||||||
|
|
||||||
|
createdAt : Comment -> Time.Posix
|
||||||
|
createdAt (Comment comment) =
|
||||||
|
comment.createdAt
|
||||||
|
|
||||||
|
|
||||||
|
author : Comment -> Author
|
||||||
|
author (Comment comment) =
|
||||||
|
comment.author
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LIST
|
||||||
|
|
||||||
|
|
||||||
|
list : Maybe Cred -> Slug -> Http.Request (List Comment)
|
||||||
|
list maybeCred articleSlug =
|
||||||
|
allCommentsUrl articleSlug []
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "comments" (Decode.list (decoder maybeCred))))
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- POST
|
||||||
|
|
||||||
|
|
||||||
|
post : Slug -> String -> Cred -> Http.Request Comment
|
||||||
|
post articleSlug commentBody cred =
|
||||||
|
allCommentsUrl articleSlug []
|
||||||
|
|> HttpBuilder.post
|
||||||
|
|> HttpBuilder.withBody (Http.jsonBody (encodeCommentBody commentBody))
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "comment" (decoder (Just cred))))
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
encodeCommentBody : String -> Value
|
||||||
|
encodeCommentBody str =
|
||||||
|
Encode.object [ ( "comment", Encode.object [ ( "body", Encode.string str ) ] ) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- DELETE
|
||||||
|
|
||||||
|
|
||||||
|
delete : Slug -> CommentId -> Cred -> Http.Request ()
|
||||||
|
delete articleSlug commentId cred =
|
||||||
|
commentUrl articleSlug commentId
|
||||||
|
|> HttpBuilder.delete
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Decoder Comment
|
||||||
|
decoder maybeCred =
|
||||||
|
Decode.succeed Internals
|
||||||
|
|> required "id" CommentId.decoder
|
||||||
|
|> required "body" Decode.string
|
||||||
|
|> required "createdAt" Timestamp.iso8601Decoder
|
||||||
|
|> required "author" (Author.decoder maybeCred)
|
||||||
|
|> Decode.map Comment
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- URLS
|
||||||
|
|
||||||
|
|
||||||
|
commentUrl : Slug -> CommentId -> String
|
||||||
|
commentUrl articleSlug commentId =
|
||||||
|
allCommentsUrl articleSlug [ CommentId.toString commentId ]
|
||||||
|
|
||||||
|
|
||||||
|
allCommentsUrl : Slug -> List String -> String
|
||||||
|
allCommentsUrl articleSlug paths =
|
||||||
|
Api.url ([ "articles", Slug.toString articleSlug, "comments" ] ++ paths)
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
module Article.Feed
|
||||||
|
exposing
|
||||||
|
( Model
|
||||||
|
, Msg
|
||||||
|
, decoder
|
||||||
|
, init
|
||||||
|
, update
|
||||||
|
, viewArticles
|
||||||
|
, viewPagination
|
||||||
|
, viewTabs
|
||||||
|
)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Preview)
|
||||||
|
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
|
||||||
|
import Article.Slug as ArticleSlug exposing (Slug)
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Author
|
||||||
|
import Avatar exposing (Avatar)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
import Page
|
||||||
|
import PaginatedList exposing (PaginatedList)
|
||||||
|
import Profile
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| NOTE: This module has its own Model, view, and update. This is not normal!
|
||||||
|
If you find yourself doing this often, please watch <https://www.youtube.com/watch?v=DoA4Txr4GUs>
|
||||||
|
|
||||||
|
This is the reusable Article Feed that appears on both the Home page as well as
|
||||||
|
on the Profile page. There's a lot of logic here, so it's more convenient to use
|
||||||
|
the heavyweight approach of giving this its own Model, view, and update.
|
||||||
|
|
||||||
|
This means callers must use Html.map and Cmd.map to use this thing, but in
|
||||||
|
this case that's totally worth it because of the amount of logic wrapped up
|
||||||
|
in this thing.
|
||||||
|
|
||||||
|
For every other reusable view in this application, this API would be totally
|
||||||
|
overkill, so we use simpler APIs instead.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Model Internals
|
||||||
|
|
||||||
|
|
||||||
|
{-| This should not be exposed! We want to benefit from the guarantee that only
|
||||||
|
this module can create or alter this model. This way if it ever ends up in
|
||||||
|
a surprising state, we know exactly where to look: this module.
|
||||||
|
-}
|
||||||
|
type alias Internals =
|
||||||
|
{ session : Session
|
||||||
|
, errors : List String
|
||||||
|
, articles : PaginatedList (Article Preview)
|
||||||
|
, isLoading : Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> PaginatedList (Article Preview) -> Model
|
||||||
|
init session articles =
|
||||||
|
Model
|
||||||
|
{ session = session
|
||||||
|
, errors = []
|
||||||
|
, articles = articles
|
||||||
|
, isLoading = False
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
viewArticles : Time.Zone -> Model -> List (Html Msg)
|
||||||
|
viewArticles timeZone (Model { articles, session, errors }) =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
|
||||||
|
articlesHtml =
|
||||||
|
PaginatedList.values articles
|
||||||
|
|> List.map (viewPreview maybeCred timeZone)
|
||||||
|
in
|
||||||
|
Page.viewErrors ClickedDismissErrors errors :: articlesHtml
|
||||||
|
|
||||||
|
|
||||||
|
viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg
|
||||||
|
viewPreview maybeCred timeZone article =
|
||||||
|
let
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
{ title, description, createdAt } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
author =
|
||||||
|
Article.author article
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
username =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
faveButton =
|
||||||
|
case maybeCred of
|
||||||
|
Just cred ->
|
||||||
|
let
|
||||||
|
{ favoritesCount, favorited } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
viewButton =
|
||||||
|
if favorited then
|
||||||
|
Article.unfavoriteButton cred (ClickedUnfavorite cred slug)
|
||||||
|
|
||||||
|
else
|
||||||
|
Article.favoriteButton cred (ClickedFavorite cred slug)
|
||||||
|
in
|
||||||
|
viewButton [ class "pull-xs-right" ]
|
||||||
|
[ text (" " ++ String.fromInt favoritesCount) ]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text ""
|
||||||
|
in
|
||||||
|
div [ class "article-preview" ]
|
||||||
|
[ div [ class "article-meta" ]
|
||||||
|
[ a [ Route.href (Route.Profile username) ]
|
||||||
|
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view username
|
||||||
|
, Timestamp.view timeZone createdAt
|
||||||
|
]
|
||||||
|
, faveButton
|
||||||
|
]
|
||||||
|
, a [ class "preview-link", Route.href (Route.Article (Article.slug article)) ]
|
||||||
|
[ h1 [] [ text title ]
|
||||||
|
, p [] [ text description ]
|
||||||
|
, span [] [ text "Read more..." ]
|
||||||
|
, ul [ class "tag-list" ]
|
||||||
|
(List.map viewTag (Article.metadata article).tags)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTabs :
|
||||||
|
List ( String, msg )
|
||||||
|
-> ( String, msg )
|
||||||
|
-> List ( String, msg )
|
||||||
|
-> Html msg
|
||||||
|
viewTabs before selected after =
|
||||||
|
ul [ class "nav nav-pills outline-active" ] <|
|
||||||
|
List.concat
|
||||||
|
[ List.map (viewTab []) before
|
||||||
|
, [ viewTab [ class "active" ] selected ]
|
||||||
|
, List.map (viewTab []) after
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewTab : List (Attribute msg) -> ( String, msg ) -> Html msg
|
||||||
|
viewTab attrs ( name, msg ) =
|
||||||
|
li [ class "nav-item" ]
|
||||||
|
[ -- Note: The RealWorld CSS requires an href to work properly.
|
||||||
|
a (class "nav-link" :: onClick msg :: href "" :: attrs)
|
||||||
|
[ text name ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewPagination : (Int -> msg) -> Model -> Html msg
|
||||||
|
viewPagination toMsg (Model feed) =
|
||||||
|
PaginatedList.view toMsg feed.articles
|
||||||
|
|
||||||
|
|
||||||
|
viewTag : String -> Html msg
|
||||||
|
viewTag tagName =
|
||||||
|
li [ class "tag-default tag-pill tag-outline" ] [ text tagName ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDismissErrors
|
||||||
|
| ClickedFavorite Cred Slug
|
||||||
|
| ClickedUnfavorite Cred Slug
|
||||||
|
| CompletedFavorite (Result Http.Error (Article Preview))
|
||||||
|
|
||||||
|
|
||||||
|
update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update maybeCred msg (Model model) =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( Model { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedFavorite cred slug ->
|
||||||
|
fave Article.favorite cred slug model
|
||||||
|
|
||||||
|
ClickedUnfavorite cred slug ->
|
||||||
|
fave Article.unfavorite cred slug model
|
||||||
|
|
||||||
|
CompletedFavorite (Ok article) ->
|
||||||
|
( Model { model | articles = PaginatedList.map (replaceArticle article) model.articles }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFavorite (Err error) ->
|
||||||
|
( Model { model | errors = Api.addServerError model.errors }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
replaceArticle : Article a -> Article a -> Article a
|
||||||
|
replaceArticle newArticle oldArticle =
|
||||||
|
if Article.slug newArticle == Article.slug oldArticle then
|
||||||
|
newArticle
|
||||||
|
|
||||||
|
else
|
||||||
|
oldArticle
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Int -> Decoder (PaginatedList (Article Preview))
|
||||||
|
decoder maybeCred resultsPerPage =
|
||||||
|
Decode.succeed PaginatedList.fromList
|
||||||
|
|> required "articlesCount" (pageCountDecoder resultsPerPage)
|
||||||
|
|> required "articles" (Decode.list (Article.previewDecoder maybeCred))
|
||||||
|
|
||||||
|
|
||||||
|
pageCountDecoder : Int -> Decoder Int
|
||||||
|
pageCountDecoder resultsPerPage =
|
||||||
|
Decode.int
|
||||||
|
|> Decode.map (\total -> ceiling (toFloat total / toFloat resultsPerPage))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Internals -> ( Model, Cmd Msg )
|
||||||
|
fave toRequest cred slug model =
|
||||||
|
( Model model
|
||||||
|
, toRequest slug cred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.attempt CompletedFavorite
|
||||||
|
)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
module Article.FeedSources exposing (FeedSources, Source(..), after, before, fromLists, select, selected)
|
||||||
|
|
||||||
|
import Article
|
||||||
|
import Article.Tag as Tag exposing (Tag)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type FeedSources
|
||||||
|
= FeedSources
|
||||||
|
{ before : List Source
|
||||||
|
, selected : Source
|
||||||
|
, after : List Source
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Source
|
||||||
|
= YourFeed Cred
|
||||||
|
| GlobalFeed
|
||||||
|
| TagFeed Tag
|
||||||
|
| FavoritedFeed Username
|
||||||
|
| AuthorFeed Username
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- BUILDING
|
||||||
|
|
||||||
|
|
||||||
|
fromLists : Source -> List Source -> FeedSources
|
||||||
|
fromLists selectedSource afterSources =
|
||||||
|
FeedSources
|
||||||
|
{ before = []
|
||||||
|
, selected = selectedSource
|
||||||
|
, after = afterSources
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SELECTING
|
||||||
|
|
||||||
|
|
||||||
|
select : Source -> FeedSources -> FeedSources
|
||||||
|
select selectedSource (FeedSources sources) =
|
||||||
|
let
|
||||||
|
( newBefore, newAfter ) =
|
||||||
|
(sources.before ++ (sources.selected :: sources.after))
|
||||||
|
-- By design, tags can only be included if they're selected.
|
||||||
|
|> List.filter isNotTag
|
||||||
|
|> splitOn (\source -> source == selectedSource)
|
||||||
|
in
|
||||||
|
FeedSources
|
||||||
|
{ before = List.reverse newBefore
|
||||||
|
, selected = selectedSource
|
||||||
|
, after = List.reverse newAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
splitOn : (Source -> Bool) -> List Source -> ( List Source, List Source )
|
||||||
|
splitOn isSelected sources =
|
||||||
|
let
|
||||||
|
( _, newBefore, newAfter ) =
|
||||||
|
List.foldl (splitOnHelp isSelected) ( False, [], [] ) sources
|
||||||
|
in
|
||||||
|
( newBefore, newAfter )
|
||||||
|
|
||||||
|
|
||||||
|
splitOnHelp : (Source -> Bool) -> Source -> ( Bool, List Source, List Source ) -> ( Bool, List Source, List Source )
|
||||||
|
splitOnHelp isSelected source ( foundSelected, beforeSelected, afterSelected ) =
|
||||||
|
if isSelected source then
|
||||||
|
( True, beforeSelected, afterSelected )
|
||||||
|
|
||||||
|
else if foundSelected then
|
||||||
|
( foundSelected, beforeSelected, source :: afterSelected )
|
||||||
|
|
||||||
|
else
|
||||||
|
( foundSelected, source :: beforeSelected, afterSelected )
|
||||||
|
|
||||||
|
|
||||||
|
isNotTag : Source -> Bool
|
||||||
|
isNotTag currentSource =
|
||||||
|
case currentSource of
|
||||||
|
TagFeed _ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INFO
|
||||||
|
|
||||||
|
|
||||||
|
selected : FeedSources -> Source
|
||||||
|
selected (FeedSources record) =
|
||||||
|
record.selected
|
||||||
|
|
||||||
|
|
||||||
|
before : FeedSources -> List Source
|
||||||
|
before (FeedSources record) =
|
||||||
|
record.before
|
||||||
|
|
||||||
|
|
||||||
|
after : FeedSources -> List Source
|
||||||
|
after (FeedSources record) =
|
||||||
|
record.after
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
module Article.Slug exposing (Slug, decoder, toString, urlParser)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Url.Parser exposing (Parser)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Slug
|
||||||
|
= Slug String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
urlParser : Parser (Slug -> a) a
|
||||||
|
urlParser =
|
||||||
|
Url.Parser.custom "SLUG" (\str -> Just (Slug str))
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Slug
|
||||||
|
decoder =
|
||||||
|
Decode.map Slug Decode.string
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : Slug -> String
|
||||||
|
toString (Slug str) =
|
||||||
|
str
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
module Article.Tag exposing (Tag, list, toString, validate)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Http
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Tag
|
||||||
|
= Tag String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : Tag -> String
|
||||||
|
toString (Tag slug) =
|
||||||
|
slug
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- LIST
|
||||||
|
|
||||||
|
|
||||||
|
list : Http.Request (List Tag)
|
||||||
|
list =
|
||||||
|
Decode.field "tags" (Decode.list decoder)
|
||||||
|
|> Http.get (Api.url [ "tags" ])
|
||||||
|
|
||||||
|
|
||||||
|
validate : String -> List String -> Bool
|
||||||
|
validate str =
|
||||||
|
String.split " " str
|
||||||
|
|> List.map String.trim
|
||||||
|
|> List.filter (not << String.isEmpty)
|
||||||
|
|> (==)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Tag
|
||||||
|
decoder =
|
||||||
|
Decode.map Tag Decode.string
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
module Asset exposing (Image, defaultAvatar, error, loading, src)
|
||||||
|
|
||||||
|
{-| Assets, such as images, videos, and audio. (We only have images for now.)
|
||||||
|
|
||||||
|
We should never expose asset URLs directly; this module should be in charge of
|
||||||
|
all of them. One source of truth!
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes as Attr
|
||||||
|
|
||||||
|
|
||||||
|
type Image
|
||||||
|
= Image String
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
error : Image
|
||||||
|
error =
|
||||||
|
image "error.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
loading : Image
|
||||||
|
loading =
|
||||||
|
image "loading.svg"
|
||||||
|
|
||||||
|
|
||||||
|
defaultAvatar : Image
|
||||||
|
defaultAvatar =
|
||||||
|
image "smiley-cyrus.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
image : String -> Image
|
||||||
|
image filename =
|
||||||
|
Image ("/assets/images/" ++ filename)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- USING IMAGES
|
||||||
|
|
||||||
|
|
||||||
|
src : Image -> Attribute msg
|
||||||
|
src (Image url) =
|
||||||
|
Attr.src url
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
module Author
|
||||||
|
exposing
|
||||||
|
( Author(..)
|
||||||
|
, FollowedAuthor
|
||||||
|
, UnfollowedAuthor
|
||||||
|
, decoder
|
||||||
|
, fetch
|
||||||
|
, follow
|
||||||
|
, followButton
|
||||||
|
, profile
|
||||||
|
, requestFollow
|
||||||
|
, requestUnfollow
|
||||||
|
, unfollow
|
||||||
|
, unfollowButton
|
||||||
|
, username
|
||||||
|
, view
|
||||||
|
)
|
||||||
|
|
||||||
|
{-| The author of an Article. It includes a Profile.
|
||||||
|
|
||||||
|
I designed this to make sure the compiler would help me keep these three
|
||||||
|
possibilities straight when displaying follow buttons and such:
|
||||||
|
|
||||||
|
- I'm following this author.
|
||||||
|
- I'm not following this author.
|
||||||
|
- I _can't_ follow this author, because it's me!
|
||||||
|
|
||||||
|
To do this, I defined `Author` a custom type with three variants, one for each
|
||||||
|
of those possibilities.
|
||||||
|
|
||||||
|
I also made separate types for FollowedAuthor and UnfollowedAuthor.
|
||||||
|
They are custom type wrappers around Profile, and thier sole purpose is to
|
||||||
|
help me keep track of which operations are supported.
|
||||||
|
|
||||||
|
For example, consider these functions:
|
||||||
|
|
||||||
|
requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
|
||||||
|
These types help the compiler prevent several mistakes:
|
||||||
|
|
||||||
|
- Displaying a Follow button for an author the user already follows.
|
||||||
|
- Displaying an Unfollow button for an author the user already doesn't follow.
|
||||||
|
- Displaying either button when the author is ourself.
|
||||||
|
|
||||||
|
There are still ways we could mess things up (e.g. make a button that calls Author.unfollow when you click it, but which displays "Follow" to the user) - but this rules out a bunch of potential problems.
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Html exposing (Html, a, i, text)
|
||||||
|
import Html.Attributes exposing (attribute, class, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withExpect)
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Decode.Pipeline exposing (custom, optional, required)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author - either the current user, another user we're following, or
|
||||||
|
another user we aren't following.
|
||||||
|
|
||||||
|
These distinctions matter because we can only perform "follow" requests for
|
||||||
|
users we aren't following, we can only perform "unfollow" requests for
|
||||||
|
users we _are_ following, and we can't perform either for ourselves.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Author
|
||||||
|
= IsFollowing FollowedAuthor
|
||||||
|
| IsNotFollowing UnfollowedAuthor
|
||||||
|
| IsViewer Cred Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author we're following.
|
||||||
|
-}
|
||||||
|
type FollowedAuthor
|
||||||
|
= FollowedAuthor Username Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| An author we're not following.
|
||||||
|
-}
|
||||||
|
type UnfollowedAuthor
|
||||||
|
= UnfollowedAuthor Username Profile
|
||||||
|
|
||||||
|
|
||||||
|
{-| Return an Author's username.
|
||||||
|
-}
|
||||||
|
username : Author -> Username
|
||||||
|
username author =
|
||||||
|
case author of
|
||||||
|
IsViewer cred _ ->
|
||||||
|
Cred.username cred
|
||||||
|
|
||||||
|
IsFollowing (FollowedAuthor val _) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsNotFollowing (UnfollowedAuthor val _) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
{-| Return an Author's profile.
|
||||||
|
-}
|
||||||
|
profile : Author -> Profile
|
||||||
|
profile author =
|
||||||
|
case author of
|
||||||
|
IsViewer _ val ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsFollowing (FollowedAuthor _ val) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
IsNotFollowing (UnfollowedAuthor _ val) ->
|
||||||
|
val
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FETCH
|
||||||
|
|
||||||
|
|
||||||
|
fetch : Username -> Maybe Cred -> Http.Request Author
|
||||||
|
fetch uname maybeCred =
|
||||||
|
Api.url [ "profiles", Username.toString uname ]
|
||||||
|
|> HttpBuilder.get
|
||||||
|
|> HttpBuilder.withExpect (Http.expectJson (Decode.field "profile" (decoder maybeCred)))
|
||||||
|
|> Cred.addHeaderIfAvailable maybeCred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FOLLOWING
|
||||||
|
|
||||||
|
|
||||||
|
follow : UnfollowedAuthor -> FollowedAuthor
|
||||||
|
follow (UnfollowedAuthor uname prof) =
|
||||||
|
FollowedAuthor uname prof
|
||||||
|
|
||||||
|
|
||||||
|
unfollow : FollowedAuthor -> UnfollowedAuthor
|
||||||
|
unfollow (FollowedAuthor uname prof) =
|
||||||
|
UnfollowedAuthor uname prof
|
||||||
|
|
||||||
|
|
||||||
|
requestFollow : UnfollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestFollow (UnfollowedAuthor uname _) cred =
|
||||||
|
requestHelp HttpBuilder.post uname cred
|
||||||
|
|
||||||
|
|
||||||
|
requestUnfollow : FollowedAuthor -> Cred -> Http.Request Author
|
||||||
|
requestUnfollow (FollowedAuthor uname _) cred =
|
||||||
|
requestHelp HttpBuilder.delete uname cred
|
||||||
|
|
||||||
|
|
||||||
|
requestHelp :
|
||||||
|
(String -> RequestBuilder a)
|
||||||
|
-> Username
|
||||||
|
-> Cred
|
||||||
|
-> Http.Request Author
|
||||||
|
requestHelp builderFromUrl uname cred =
|
||||||
|
Api.url [ "profiles", Username.toString uname, "follow" ]
|
||||||
|
|> builderFromUrl
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withExpect (Http.expectJson (Decode.field "profile" (decoder Nothing)))
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
followButton : (Cred -> UnfollowedAuthor -> msg) -> Cred -> UnfollowedAuthor -> Html msg
|
||||||
|
followButton toMsg cred ((UnfollowedAuthor uname _) as author) =
|
||||||
|
toggleFollowButton "Follow"
|
||||||
|
[ "btn-outline-secondary" ]
|
||||||
|
(toMsg cred author)
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
unfollowButton : (Cred -> FollowedAuthor -> msg) -> Cred -> FollowedAuthor -> Html msg
|
||||||
|
unfollowButton toMsg cred ((FollowedAuthor uname _) as author) =
|
||||||
|
toggleFollowButton "Unfollow"
|
||||||
|
[ "btn-secondary" ]
|
||||||
|
(toMsg cred author)
|
||||||
|
uname
|
||||||
|
|
||||||
|
|
||||||
|
toggleFollowButton : String -> List String -> msg -> Username -> Html msg
|
||||||
|
toggleFollowButton txt extraClasses msgWhenClicked uname =
|
||||||
|
let
|
||||||
|
classStr =
|
||||||
|
"btn btn-sm " ++ String.join " " extraClasses ++ " action-btn"
|
||||||
|
|
||||||
|
caption =
|
||||||
|
" " ++ txt ++ " " ++ Username.toString uname
|
||||||
|
in
|
||||||
|
Html.button [ class classStr, onClick msgWhenClicked ]
|
||||||
|
[ i [ class "ion-plus-round" ] []
|
||||||
|
, text caption
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SERIALIZATION
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Maybe Cred -> Decoder Author
|
||||||
|
decoder maybeCred =
|
||||||
|
Decode.succeed Tuple.pair
|
||||||
|
|> custom Profile.decoder
|
||||||
|
|> required "username" Username.decoder
|
||||||
|
|> Decode.andThen (decodeFromPair maybeCred)
|
||||||
|
|
||||||
|
|
||||||
|
decodeFromPair : Maybe Cred -> ( Profile, Username ) -> Decoder Author
|
||||||
|
decodeFromPair maybeCred ( prof, uname ) =
|
||||||
|
case maybeCred of
|
||||||
|
Nothing ->
|
||||||
|
-- If you're logged out, you can't be following anyone!
|
||||||
|
Decode.succeed (IsNotFollowing (UnfollowedAuthor uname prof))
|
||||||
|
|
||||||
|
Just cred ->
|
||||||
|
if uname == Cred.username cred then
|
||||||
|
Decode.succeed (IsViewer cred prof)
|
||||||
|
|
||||||
|
else
|
||||||
|
nonViewerDecoder prof uname
|
||||||
|
|
||||||
|
|
||||||
|
nonViewerDecoder : Profile -> Username -> Decoder Author
|
||||||
|
nonViewerDecoder prof uname =
|
||||||
|
Decode.succeed (authorFromFollowing prof uname)
|
||||||
|
|> optional "following" Decode.bool False
|
||||||
|
|
||||||
|
|
||||||
|
authorFromFollowing : Profile -> Username -> Bool -> Author
|
||||||
|
authorFromFollowing prof uname isFollowing =
|
||||||
|
if isFollowing then
|
||||||
|
IsFollowing (FollowedAuthor uname prof)
|
||||||
|
|
||||||
|
else
|
||||||
|
IsNotFollowing (UnfollowedAuthor uname prof)
|
||||||
|
|
||||||
|
|
||||||
|
{-| View an author. We basically render their username and a link to their
|
||||||
|
profile, and that's it.
|
||||||
|
-}
|
||||||
|
view : Username -> Html msg
|
||||||
|
view uname =
|
||||||
|
a [ class "author", Route.href (Route.Profile uname) ]
|
||||||
|
[ Username.toHtml uname ]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute)
|
||||||
|
import Html.Attributes
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type Avatar
|
||||||
|
= Avatar (Maybe String)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Avatar
|
||||||
|
decoder =
|
||||||
|
Decode.map Avatar (Decode.nullable Decode.string)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
encode : Avatar -> Value
|
||||||
|
encode (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Just url ->
|
||||||
|
Encode.string url
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
Encode.null
|
||||||
|
|
||||||
|
|
||||||
|
src : Avatar -> Attribute msg
|
||||||
|
src (Avatar maybeUrl) =
|
||||||
|
case maybeUrl of
|
||||||
|
Nothing ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just "" ->
|
||||||
|
Asset.src Asset.defaultAvatar
|
||||||
|
|
||||||
|
Just url ->
|
||||||
|
Html.Attributes.src url
|
||||||
|
|
||||||
|
|
||||||
|
toMaybeString : Avatar -> Maybe String
|
||||||
|
toMaybeString (Avatar maybeUrl) =
|
||||||
|
maybeUrl
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module CommentId exposing (CommentId, decoder, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TYPES
|
||||||
|
|
||||||
|
|
||||||
|
type CommentId
|
||||||
|
= CommentId Int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- CREATE
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder CommentId
|
||||||
|
decoder =
|
||||||
|
Decode.map CommentId Decode.int
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- TRANSFORM
|
||||||
|
|
||||||
|
|
||||||
|
toString : CommentId -> String
|
||||||
|
toString (CommentId id) =
|
||||||
|
String.fromInt id
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
module Email exposing (Email, decoder, encode, toString)
|
||||||
|
|
||||||
|
import Json.Decode as Decode exposing (Decoder)
|
||||||
|
import Json.Encode as Encode exposing (Value)
|
||||||
|
|
||||||
|
|
||||||
|
{-| An email address.
|
||||||
|
|
||||||
|
Having this as a custom type that's separate from String makes certain
|
||||||
|
mistakes impossible. Consider this function:
|
||||||
|
|
||||||
|
updateEmailAddress : Email -> String -> Http.Request
|
||||||
|
updateEmailAddress email password = ...
|
||||||
|
|
||||||
|
(The server needs your password to confirm that you should be allowed
|
||||||
|
to update the email address.)
|
||||||
|
|
||||||
|
Because Email is not a type alias for String, but is instead a separate
|
||||||
|
custom type, it is now impossible to mix up the argument order of the
|
||||||
|
email and the password. If we do, it won't compile!
|
||||||
|
|
||||||
|
If Email were instead defined as `type alias Email = String`, we could
|
||||||
|
call updateEmailAddress password email and it would compile (and never
|
||||||
|
work properly).
|
||||||
|
|
||||||
|
This way, we make it impossible for a bug like that to compile!
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Email
|
||||||
|
= Email String
|
||||||
|
|
||||||
|
|
||||||
|
toString : Email -> String
|
||||||
|
toString (Email str) =
|
||||||
|
str
|
||||||
|
|
||||||
|
|
||||||
|
encode : Email -> Value
|
||||||
|
encode (Email str) =
|
||||||
|
Encode.string str
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder Email
|
||||||
|
decoder =
|
||||||
|
Decode.map Email Decode.string
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module Loading exposing (error, icon, slowThreshold)
|
||||||
|
|
||||||
|
{-| A loading spinner icon.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Asset
|
||||||
|
import Html exposing (Attribute, Html)
|
||||||
|
import Html.Attributes exposing (alt, height, src, width)
|
||||||
|
import Process
|
||||||
|
import Task exposing (Task)
|
||||||
|
|
||||||
|
|
||||||
|
icon : Html msg
|
||||||
|
icon =
|
||||||
|
Html.img [ Asset.src Asset.loading, width 64, height 64, alt "Loading..." ] []
|
||||||
|
|
||||||
|
|
||||||
|
error : String -> Html msg
|
||||||
|
error str =
|
||||||
|
Html.text ("Error loading " ++ str ++ ".")
|
||||||
|
|
||||||
|
|
||||||
|
slowThreshold : Task x ()
|
||||||
|
slowThreshold =
|
||||||
|
Process.sleep 500
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
module Log exposing (error)
|
||||||
|
|
||||||
|
{-| This is a placeholder API for how we might do logging through
|
||||||
|
some service like <http://rollbar.com> (which is what we use at work).
|
||||||
|
|
||||||
|
Whenever you see Log.error used in this code base, it means
|
||||||
|
"Something unexpected happened. This is where we would log an
|
||||||
|
error to our server with some diagnostic info so we could investigate
|
||||||
|
what happened later."
|
||||||
|
|
||||||
|
(Since this is outside the scope of the RealWorld spec, and is only
|
||||||
|
a placeholder anyway, I didn't bother making this function accept actual
|
||||||
|
diagnostic info, authentication tokens, etc.)
|
||||||
|
|
||||||
|
-}
|
||||||
|
|
||||||
|
|
||||||
|
error : Cmd msg
|
||||||
|
error =
|
||||||
|
Cmd.none
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
module Main exposing (main)
|
||||||
|
|
||||||
|
import Article.FeedSources as FeedSources
|
||||||
|
import Article.Slug exposing (Slug)
|
||||||
|
import Browser exposing (Document)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Json.Decode as Decode exposing (Value)
|
||||||
|
import Page exposing (Page)
|
||||||
|
import Page.Article as Article
|
||||||
|
import Page.Article.Editor as Editor
|
||||||
|
import Page.Blank as Blank
|
||||||
|
import Page.Home as Home
|
||||||
|
import Page.Login as Login
|
||||||
|
import Page.NotFound as NotFound
|
||||||
|
import Page.Profile as Profile
|
||||||
|
import Page.Register as Register
|
||||||
|
import Page.Settings as Settings
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task
|
||||||
|
import Time
|
||||||
|
import Url exposing (Url)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- WARNING: Based on discussions around how asset management features
|
||||||
|
-- like code splitting and lazy loading have been shaping up, I expect
|
||||||
|
-- most of this file to become unnecessary in a future release of Elm.
|
||||||
|
-- Avoid putting things in here unless there is no alternative!
|
||||||
|
|
||||||
|
|
||||||
|
type Model
|
||||||
|
= Redirect Session
|
||||||
|
| NotFound Session
|
||||||
|
| Home Home.Model
|
||||||
|
| Settings Settings.Model
|
||||||
|
| Login Login.Model
|
||||||
|
| Register Register.Model
|
||||||
|
| Profile Username Profile.Model
|
||||||
|
| Article Article.Model
|
||||||
|
| Editor (Maybe Slug) Editor.Model
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
|
||||||
|
init flags url navKey =
|
||||||
|
changeRouteTo (Route.fromUrl url)
|
||||||
|
(Redirect (Session.decode navKey flags))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> Document Msg
|
||||||
|
view model =
|
||||||
|
let
|
||||||
|
viewPage page toMsg config =
|
||||||
|
let
|
||||||
|
{ title, body } =
|
||||||
|
Page.view (Session.viewer (toSession model)) page config
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, body = List.map (Html.map toMsg) body
|
||||||
|
}
|
||||||
|
in
|
||||||
|
case model of
|
||||||
|
Redirect _ ->
|
||||||
|
viewPage Page.Other (\_ -> Ignored) Blank.view
|
||||||
|
|
||||||
|
NotFound _ ->
|
||||||
|
viewPage Page.Other (\_ -> Ignored) NotFound.view
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
viewPage Page.Other GotSettingsMsg (Settings.view settings)
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
viewPage Page.Home GotHomeMsg (Home.view home)
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
viewPage Page.Other GotLoginMsg (Login.view login)
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
viewPage Page.Other GotRegisterMsg (Register.view register)
|
||||||
|
|
||||||
|
Profile username profile ->
|
||||||
|
viewPage (Page.Profile username) GotProfileMsg (Profile.view profile)
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
viewPage Page.Other GotArticleMsg (Article.view article)
|
||||||
|
|
||||||
|
Editor Nothing editor ->
|
||||||
|
viewPage Page.NewArticle GotEditorMsg (Editor.view editor)
|
||||||
|
|
||||||
|
Editor (Just _) editor ->
|
||||||
|
viewPage Page.Other GotEditorMsg (Editor.view editor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= Ignored
|
||||||
|
| ChangedRoute (Maybe Route)
|
||||||
|
| ChangedUrl Url
|
||||||
|
| ClickedLink Browser.UrlRequest
|
||||||
|
| GotHomeMsg Home.Msg
|
||||||
|
| GotSettingsMsg Settings.Msg
|
||||||
|
| GotLoginMsg Login.Msg
|
||||||
|
| GotRegisterMsg Register.Msg
|
||||||
|
| GotProfileMsg Profile.Msg
|
||||||
|
| GotArticleMsg Article.Msg
|
||||||
|
| GotEditorMsg Editor.Msg
|
||||||
|
| GotSession Session
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession page =
|
||||||
|
case page of
|
||||||
|
Redirect session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
NotFound session ->
|
||||||
|
session
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Home.toSession home
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
Settings.toSession settings
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Login.toSession login
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
Register.toSession register
|
||||||
|
|
||||||
|
Profile _ profile ->
|
||||||
|
Profile.toSession profile
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
Article.toSession article
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Editor.toSession editor
|
||||||
|
|
||||||
|
|
||||||
|
changeRouteTo : Maybe Route -> Model -> ( Model, Cmd Msg )
|
||||||
|
changeRouteTo maybeRoute model =
|
||||||
|
let
|
||||||
|
session =
|
||||||
|
toSession model
|
||||||
|
in
|
||||||
|
case maybeRoute of
|
||||||
|
Nothing ->
|
||||||
|
( NotFound session, Cmd.none )
|
||||||
|
|
||||||
|
Just Route.Root ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey session) Route.Home )
|
||||||
|
|
||||||
|
Just Route.Logout ->
|
||||||
|
( model, Session.logout )
|
||||||
|
|
||||||
|
Just Route.NewArticle ->
|
||||||
|
Editor.initNew session
|
||||||
|
|> updateWith (Editor Nothing) GotEditorMsg model
|
||||||
|
|
||||||
|
Just (Route.EditArticle slug) ->
|
||||||
|
Editor.initEdit session slug
|
||||||
|
|> updateWith (Editor (Just slug)) GotEditorMsg model
|
||||||
|
|
||||||
|
Just Route.Settings ->
|
||||||
|
Settings.init session
|
||||||
|
|> updateWith Settings GotSettingsMsg model
|
||||||
|
|
||||||
|
Just Route.Home ->
|
||||||
|
Home.init session
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
Just Route.Login ->
|
||||||
|
Login.init session
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
Just Route.Register ->
|
||||||
|
Register.init session
|
||||||
|
|> updateWith Register GotRegisterMsg model
|
||||||
|
|
||||||
|
Just (Route.Profile username) ->
|
||||||
|
Profile.init session username
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
Just (Route.Article slug) ->
|
||||||
|
Article.init session slug
|
||||||
|
|> updateWith Article GotArticleMsg model
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case ( msg, model ) of
|
||||||
|
( Ignored, _ ) ->
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
( ClickedLink urlRequest, _ ) ->
|
||||||
|
case urlRequest of
|
||||||
|
Browser.Internal url ->
|
||||||
|
case url.fragment of
|
||||||
|
Nothing ->
|
||||||
|
-- If we got a link that didn't include a fragment,
|
||||||
|
-- it's from one of those (href "") attributes that
|
||||||
|
-- we have to include to make the RealWorld CSS work.
|
||||||
|
--
|
||||||
|
-- In an application doing path routing instead of
|
||||||
|
-- fragment-based routing, this entire
|
||||||
|
-- `case url.fragment of` expression this comment
|
||||||
|
-- is inside would be unnecessary.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
Just _ ->
|
||||||
|
( model
|
||||||
|
, Nav.pushUrl (Session.navKey (toSession model)) (Url.toString url)
|
||||||
|
)
|
||||||
|
|
||||||
|
Browser.External href ->
|
||||||
|
( model
|
||||||
|
, Nav.load href
|
||||||
|
)
|
||||||
|
|
||||||
|
( ChangedUrl url, _ ) ->
|
||||||
|
changeRouteTo (Route.fromUrl url) model
|
||||||
|
|
||||||
|
( ChangedRoute route, _ ) ->
|
||||||
|
changeRouteTo route model
|
||||||
|
|
||||||
|
( GotSettingsMsg subMsg, Settings settings ) ->
|
||||||
|
Settings.update subMsg settings
|
||||||
|
|> updateWith Settings GotSettingsMsg model
|
||||||
|
|
||||||
|
( GotLoginMsg subMsg, Login login ) ->
|
||||||
|
Login.update subMsg login
|
||||||
|
|> updateWith Login GotLoginMsg model
|
||||||
|
|
||||||
|
( GotRegisterMsg subMsg, Register register ) ->
|
||||||
|
Register.update subMsg register
|
||||||
|
|> updateWith Register GotRegisterMsg model
|
||||||
|
|
||||||
|
( GotHomeMsg subMsg, Home home ) ->
|
||||||
|
Home.update subMsg home
|
||||||
|
|> updateWith Home GotHomeMsg model
|
||||||
|
|
||||||
|
( GotProfileMsg subMsg, Profile username profile ) ->
|
||||||
|
Profile.update subMsg profile
|
||||||
|
|> updateWith (Profile username) GotProfileMsg model
|
||||||
|
|
||||||
|
( GotArticleMsg subMsg, Article article ) ->
|
||||||
|
Article.update subMsg article
|
||||||
|
|> updateWith Article GotArticleMsg model
|
||||||
|
|
||||||
|
( GotEditorMsg subMsg, Editor slug editor ) ->
|
||||||
|
Editor.update subMsg editor
|
||||||
|
|> updateWith (Editor slug) GotEditorMsg model
|
||||||
|
|
||||||
|
( GotSession session, Redirect _ ) ->
|
||||||
|
( Redirect session
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
( _, _ ) ->
|
||||||
|
-- Disregard messages that arrived for the wrong page.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
updateWith : (subModel -> Model) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg )
|
||||||
|
updateWith toModel toMsg model ( subModel, subCmd ) =
|
||||||
|
( toModel subModel
|
||||||
|
, Cmd.map toMsg subCmd
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
case model of
|
||||||
|
NotFound _ ->
|
||||||
|
Sub.none
|
||||||
|
|
||||||
|
Redirect _ ->
|
||||||
|
Session.changes GotSession (Session.navKey (toSession model))
|
||||||
|
|
||||||
|
Settings settings ->
|
||||||
|
Sub.map GotSettingsMsg (Settings.subscriptions settings)
|
||||||
|
|
||||||
|
Home home ->
|
||||||
|
Sub.map GotHomeMsg (Home.subscriptions home)
|
||||||
|
|
||||||
|
Login login ->
|
||||||
|
Sub.map GotLoginMsg (Login.subscriptions login)
|
||||||
|
|
||||||
|
Register register ->
|
||||||
|
Sub.map GotRegisterMsg (Register.subscriptions register)
|
||||||
|
|
||||||
|
Profile _ profile ->
|
||||||
|
Sub.map GotProfileMsg (Profile.subscriptions profile)
|
||||||
|
|
||||||
|
Article article ->
|
||||||
|
Sub.map GotArticleMsg (Article.subscriptions article)
|
||||||
|
|
||||||
|
Editor _ editor ->
|
||||||
|
Sub.map GotEditorMsg (Editor.subscriptions editor)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MAIN
|
||||||
|
|
||||||
|
|
||||||
|
main : Program Value Model Msg
|
||||||
|
main =
|
||||||
|
Browser.application
|
||||||
|
{ init = init
|
||||||
|
, onUrlChange = ChangedUrl
|
||||||
|
, onUrlRequest = ClickedLink
|
||||||
|
, subscriptions = subscriptions
|
||||||
|
, update = update
|
||||||
|
, view = view
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
module Page exposing (Page(..), view, viewErrors)
|
||||||
|
|
||||||
|
import Avatar
|
||||||
|
import Browser exposing (Document)
|
||||||
|
import Html exposing (Html, a, button, div, footer, i, img, li, nav, p, span, text, ul)
|
||||||
|
import Html.Attributes exposing (class, classList, href, style)
|
||||||
|
import Html.Events exposing (onClick)
|
||||||
|
import Profile
|
||||||
|
import Route exposing (Route)
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
{-| Determines which navbar link (if any) will be rendered as active.
|
||||||
|
|
||||||
|
Note that we don't enumerate every page here, because the navbar doesn't
|
||||||
|
have links for every page. Anything that's not part of the navbar falls
|
||||||
|
under Other.
|
||||||
|
|
||||||
|
-}
|
||||||
|
type Page
|
||||||
|
= Other
|
||||||
|
| Home
|
||||||
|
| Login
|
||||||
|
| Register
|
||||||
|
| Settings
|
||||||
|
| Profile Username
|
||||||
|
| NewArticle
|
||||||
|
|
||||||
|
|
||||||
|
{-| Take a page's Html and frames it with a header and footer.
|
||||||
|
|
||||||
|
The caller provides the current user, so we can display in either
|
||||||
|
"signed in" (rendering username) or "signed out" mode.
|
||||||
|
|
||||||
|
isLoading is for determining whether we should show a loading spinner
|
||||||
|
in the header. (This comes up during slow page transitions.)
|
||||||
|
|
||||||
|
-}
|
||||||
|
view : Maybe Viewer -> Page -> { title : String, content : Html msg } -> Document msg
|
||||||
|
view maybeViewer page { title, content } =
|
||||||
|
{ title = title ++ " - Conduit"
|
||||||
|
, body = viewHeader page maybeViewer :: content :: [ viewFooter ]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewHeader : Page -> Maybe Viewer -> Html msg
|
||||||
|
viewHeader page maybeViewer =
|
||||||
|
nav [ class "navbar navbar-light" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ a [ class "navbar-brand", Route.href Route.Home ]
|
||||||
|
[ text "conduit" ]
|
||||||
|
, ul [ class "nav navbar-nav pull-xs-right" ] <|
|
||||||
|
navbarLink page Route.Home [ text "Home" ]
|
||||||
|
:: viewMenu page maybeViewer
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewMenu : Page -> Maybe Viewer -> List (Html msg)
|
||||||
|
viewMenu page maybeViewer =
|
||||||
|
let
|
||||||
|
linkTo =
|
||||||
|
navbarLink page
|
||||||
|
in
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
|
||||||
|
username =
|
||||||
|
Cred.username cred
|
||||||
|
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Viewer.profile viewer)
|
||||||
|
in
|
||||||
|
[ linkTo Route.NewArticle [ i [ class "ion-compose" ] [], text " New Post" ]
|
||||||
|
, linkTo Route.Settings [ i [ class "ion-gear-a" ] [], text " Settings" ]
|
||||||
|
, linkTo
|
||||||
|
(Route.Profile username)
|
||||||
|
[ img [ class "user-pic", Avatar.src avatar ] []
|
||||||
|
, Username.toHtml username
|
||||||
|
]
|
||||||
|
, linkTo Route.Logout [ text "Sign out" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[ linkTo Route.Login [ text "Sign in" ]
|
||||||
|
, linkTo Route.Register [ text "Sign up" ]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewFooter : Html msg
|
||||||
|
viewFooter =
|
||||||
|
footer []
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ a [ class "logo-font", href "/" ] [ text "conduit" ]
|
||||||
|
, span [ class "attribution" ]
|
||||||
|
[ text "An interactive learning project from "
|
||||||
|
, a [ href "https://thinkster.io" ] [ text "Thinkster" ]
|
||||||
|
, text ". Code & design licensed under MIT."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
navbarLink : Page -> Route -> List (Html msg) -> Html msg
|
||||||
|
navbarLink page route linkContent =
|
||||||
|
li [ classList [ ( "nav-item", True ), ( "active", isActive page route ) ] ]
|
||||||
|
[ a [ class "nav-link", Route.href route ] linkContent ]
|
||||||
|
|
||||||
|
|
||||||
|
isActive : Page -> Route -> Bool
|
||||||
|
isActive page route =
|
||||||
|
case ( page, route ) of
|
||||||
|
( Home, Route.Home ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Login, Route.Login ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Register, Route.Register ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Settings, Route.Settings ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
( Profile pageUsername, Route.Profile routeUsername ) ->
|
||||||
|
pageUsername == routeUsername
|
||||||
|
|
||||||
|
( NewArticle, Route.NewArticle ) ->
|
||||||
|
True
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
False
|
||||||
|
|
||||||
|
|
||||||
|
{-| Render dismissable errors. We use this all over the place!
|
||||||
|
-}
|
||||||
|
viewErrors : msg -> List String -> Html msg
|
||||||
|
viewErrors dismissErrors errors =
|
||||||
|
if List.isEmpty errors then
|
||||||
|
Html.text ""
|
||||||
|
|
||||||
|
else
|
||||||
|
div
|
||||||
|
[ class "error-messages"
|
||||||
|
, style "position" "fixed"
|
||||||
|
, style "top" "0"
|
||||||
|
, style "background" "rgb(250, 250, 250)"
|
||||||
|
, style "padding" "20px"
|
||||||
|
, style "border" "1px solid"
|
||||||
|
]
|
||||||
|
<|
|
||||||
|
List.map (\error -> p [] [ text error ]) errors
|
||||||
|
++ [ button [ onClick dismissErrors ] [ text "Ok" ] ]
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
module Page.Article exposing (Model, Msg, init, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
{-| Viewing an individual article.
|
||||||
|
-}
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Full, Preview)
|
||||||
|
import Article.Body exposing (Body)
|
||||||
|
import Article.Comment as Comment exposing (Comment)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor)
|
||||||
|
import Avatar
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import CommentId exposing (CommentId)
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder)
|
||||||
|
import Html.Events exposing (onClick, onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (RequestBuilder, withBody, withExpect, withQueryParams)
|
||||||
|
import Loading
|
||||||
|
import Log
|
||||||
|
import Page
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Timestamp
|
||||||
|
import Username exposing (Username)
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, timeZone : Time.Zone
|
||||||
|
, errors : List String
|
||||||
|
|
||||||
|
-- Loaded independently from server
|
||||||
|
, comments : Status ( CommentText, List Comment )
|
||||||
|
, article : Status (Article Full)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type Status a
|
||||||
|
= Loading
|
||||||
|
| LoadingSlowly
|
||||||
|
| Loaded a
|
||||||
|
| Failed
|
||||||
|
|
||||||
|
|
||||||
|
type CommentText
|
||||||
|
= Editing String
|
||||||
|
| Sending String
|
||||||
|
|
||||||
|
|
||||||
|
init : Session -> Slug -> ( Model, Cmd Msg )
|
||||||
|
init session slug =
|
||||||
|
let
|
||||||
|
maybeCred =
|
||||||
|
Session.cred session
|
||||||
|
in
|
||||||
|
( { session = session
|
||||||
|
, timeZone = Time.utc
|
||||||
|
, errors = []
|
||||||
|
, comments = Loading
|
||||||
|
, article = Loading
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Article.fetch maybeCred slug
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.attempt CompletedLoadArticle
|
||||||
|
, Comment.list maybeCred slug
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.attempt CompletedLoadComments
|
||||||
|
, Time.here
|
||||||
|
|> Task.perform GotTimeZone
|
||||||
|
, Loading.slowThreshold
|
||||||
|
|> Task.perform (\_ -> PassedSlowLoadThreshold)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
case model.article of
|
||||||
|
Loaded article ->
|
||||||
|
let
|
||||||
|
{ title } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
author =
|
||||||
|
Article.author article
|
||||||
|
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Author.profile author)
|
||||||
|
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
buttons =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
viewButtons cred article author
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
[]
|
||||||
|
in
|
||||||
|
{ title = title
|
||||||
|
, content =
|
||||||
|
div [ class "article-page" ]
|
||||||
|
[ div [ class "banner" ]
|
||||||
|
[ div [ class "container" ]
|
||||||
|
[ h1 [] [ text title ]
|
||||||
|
, div [ class "article-meta" ] <|
|
||||||
|
List.append
|
||||||
|
[ a [ Route.href (Route.Profile (Author.username author)) ]
|
||||||
|
[ img [ Avatar.src (Profile.avatar profile) ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view (Author.username author)
|
||||||
|
, Timestamp.view model.timeZone (Article.metadata article).createdAt
|
||||||
|
]
|
||||||
|
]
|
||||||
|
buttons
|
||||||
|
, Page.viewErrors ClickedDismissErrors model.errors
|
||||||
|
]
|
||||||
|
]
|
||||||
|
, div [ class "container page" ]
|
||||||
|
[ div [ class "row article-content" ]
|
||||||
|
[ div [ class "col-md-12" ]
|
||||||
|
[ Article.Body.toHtml (Article.body article) [] ]
|
||||||
|
]
|
||||||
|
, hr [] []
|
||||||
|
, div [ class "article-actions" ]
|
||||||
|
[ div [ class "article-meta" ] <|
|
||||||
|
List.append
|
||||||
|
[ a [ Route.href (Route.Profile (Author.username author)) ]
|
||||||
|
[ img [ Avatar.src avatar ] [] ]
|
||||||
|
, div [ class "info" ]
|
||||||
|
[ Author.view (Author.username author)
|
||||||
|
, Timestamp.view model.timeZone (Article.metadata article).createdAt
|
||||||
|
]
|
||||||
|
]
|
||||||
|
buttons
|
||||||
|
]
|
||||||
|
, div [ class "row" ]
|
||||||
|
[ div [ class "col-xs-12 col-md-8 offset-md-2" ] <|
|
||||||
|
-- Don't render the comments until the article has loaded!
|
||||||
|
case model.comments of
|
||||||
|
Loading ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Loaded ( commentText, comments ) ->
|
||||||
|
-- Don't let users add comments until they can
|
||||||
|
-- see the existing comments! Otherwise you
|
||||||
|
-- may be about to repeat something that's
|
||||||
|
-- already been said.
|
||||||
|
viewAddComment slug commentText (Session.viewer model.session)
|
||||||
|
:: List.map (viewComment model.timeZone slug) comments
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
[ Loading.error "comments" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Loading ->
|
||||||
|
{ title = "Article", content = text "" }
|
||||||
|
|
||||||
|
LoadingSlowly ->
|
||||||
|
{ title = "Article", content = Loading.icon }
|
||||||
|
|
||||||
|
Failed ->
|
||||||
|
{ title = "Article", content = Loading.error "article" }
|
||||||
|
|
||||||
|
|
||||||
|
viewAddComment : Slug -> CommentText -> Maybe Viewer -> Html Msg
|
||||||
|
viewAddComment slug commentText maybeViewer =
|
||||||
|
case maybeViewer of
|
||||||
|
Just viewer ->
|
||||||
|
let
|
||||||
|
avatar =
|
||||||
|
Profile.avatar (Viewer.profile viewer)
|
||||||
|
|
||||||
|
cred =
|
||||||
|
Viewer.cred viewer
|
||||||
|
|
||||||
|
( commentStr, buttonAttrs ) =
|
||||||
|
case commentText of
|
||||||
|
Editing str ->
|
||||||
|
( str, [] )
|
||||||
|
|
||||||
|
Sending str ->
|
||||||
|
( str, [ disabled True ] )
|
||||||
|
in
|
||||||
|
Html.form [ class "card comment-form", onSubmit (ClickedPostComment cred slug) ]
|
||||||
|
[ div [ class "card-block" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Write a comment..."
|
||||||
|
, attribute "rows" "3"
|
||||||
|
, onInput EnteredCommentText
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, div [ class "card-footer" ]
|
||||||
|
[ img [ class "comment-author-img", Avatar.src avatar ] []
|
||||||
|
, button
|
||||||
|
(class "btn btn-sm btn-primary" :: buttonAttrs)
|
||||||
|
[ text "Post Comment" ]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
p []
|
||||||
|
[ a [ Route.href Route.Login ] [ text "Sign in" ]
|
||||||
|
, text " or "
|
||||||
|
, a [ Route.href Route.Register ] [ text "sign up" ]
|
||||||
|
, text " to comment."
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewButtons : Cred -> Article Full -> Author -> List (Html Msg)
|
||||||
|
viewButtons cred article author =
|
||||||
|
case author of
|
||||||
|
IsFollowing followedAuthor ->
|
||||||
|
[ Author.unfollowButton ClickedUnfollow cred followedAuthor
|
||||||
|
, text " "
|
||||||
|
, favoriteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
IsNotFollowing unfollowedAuthor ->
|
||||||
|
[ Author.followButton ClickedFollow cred unfollowedAuthor
|
||||||
|
, text " "
|
||||||
|
, favoriteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
IsViewer _ _ ->
|
||||||
|
[ editButton article
|
||||||
|
, text " "
|
||||||
|
, deleteButton cred article
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewComment : Time.Zone -> Slug -> Comment -> Html Msg
|
||||||
|
viewComment timeZone slug comment =
|
||||||
|
let
|
||||||
|
author =
|
||||||
|
Comment.author comment
|
||||||
|
|
||||||
|
profile =
|
||||||
|
Author.profile author
|
||||||
|
|
||||||
|
authorUsername =
|
||||||
|
Author.username author
|
||||||
|
|
||||||
|
deleteCommentButton =
|
||||||
|
case author of
|
||||||
|
IsViewer cred _ ->
|
||||||
|
let
|
||||||
|
msg =
|
||||||
|
ClickedDeleteComment cred slug (Comment.id comment)
|
||||||
|
in
|
||||||
|
span
|
||||||
|
[ class "mod-options"
|
||||||
|
, onClick msg
|
||||||
|
]
|
||||||
|
[ i [ class "ion-trash-a" ] [] ]
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- You can't delete other peoples' comments!
|
||||||
|
text ""
|
||||||
|
|
||||||
|
timestamp =
|
||||||
|
Timestamp.format timeZone (Comment.createdAt comment)
|
||||||
|
in
|
||||||
|
div [ class "card" ]
|
||||||
|
[ div [ class "card-block" ]
|
||||||
|
[ p [ class "card-text" ] [ text (Comment.body comment) ] ]
|
||||||
|
, div [ class "card-footer" ]
|
||||||
|
[ a [ class "comment-author", href "" ]
|
||||||
|
[ img [ class "comment-author-img", Avatar.src (Profile.avatar profile) ] []
|
||||||
|
, text " "
|
||||||
|
]
|
||||||
|
, text " "
|
||||||
|
, a [ class "comment-author", Route.href (Route.Profile authorUsername) ]
|
||||||
|
[ text (Username.toString authorUsername) ]
|
||||||
|
, span [ class "date-posted" ] [ text timestamp ]
|
||||||
|
, deleteCommentButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedDeleteArticle Cred Slug
|
||||||
|
| ClickedDeleteComment Cred Slug CommentId
|
||||||
|
| ClickedDismissErrors
|
||||||
|
| ClickedFavorite Cred Slug Body
|
||||||
|
| ClickedUnfavorite Cred Slug Body
|
||||||
|
| ClickedFollow Cred UnfollowedAuthor
|
||||||
|
| ClickedUnfollow Cred FollowedAuthor
|
||||||
|
| ClickedPostComment Cred Slug
|
||||||
|
| EnteredCommentText String
|
||||||
|
| CompletedLoadArticle (Result Http.Error (Article Full))
|
||||||
|
| CompletedLoadComments (Result Http.Error (List Comment))
|
||||||
|
| CompletedDeleteArticle (Result Http.Error ())
|
||||||
|
| CompletedDeleteComment CommentId (Result Http.Error ())
|
||||||
|
| CompletedFavoriteChange (Result Http.Error (Article Full))
|
||||||
|
| CompletedFollowChange (Result Http.Error Author)
|
||||||
|
| CompletedPostComment (Result Http.Error Comment)
|
||||||
|
| GotTimeZone Time.Zone
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedDismissErrors ->
|
||||||
|
( { model | errors = [] }, Cmd.none )
|
||||||
|
|
||||||
|
ClickedFavorite cred slug body ->
|
||||||
|
( model, fave Article.favorite cred slug body )
|
||||||
|
|
||||||
|
ClickedUnfavorite cred slug body ->
|
||||||
|
( model, fave Article.unfavorite cred slug body )
|
||||||
|
|
||||||
|
CompletedLoadArticle (Ok article) ->
|
||||||
|
( { model | article = Loaded article }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedLoadArticle (Err error) ->
|
||||||
|
( { model | article = Failed }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedLoadComments (Ok comments) ->
|
||||||
|
( { model | comments = Loaded ( Editing "", comments ) }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedLoadComments (Err error) ->
|
||||||
|
( { model | article = Failed }, Log.error )
|
||||||
|
|
||||||
|
CompletedFavoriteChange (Ok newArticle) ->
|
||||||
|
( { model | article = Loaded newArticle }, Cmd.none )
|
||||||
|
|
||||||
|
CompletedFavoriteChange (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedUnfollow cred followedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestUnfollow followedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedFollow cred unfollowedAuthor ->
|
||||||
|
( model
|
||||||
|
, Author.requestFollow unfollowedAuthor cred
|
||||||
|
|> Http.send CompletedFollowChange
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedFollowChange (Ok newAuthor) ->
|
||||||
|
case model.article of
|
||||||
|
Loaded article ->
|
||||||
|
( { model | article = Loaded (Article.mapAuthor (\_ -> newAuthor) article) }, Cmd.none )
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedFollowChange (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
EnteredCommentText str ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( Editing _, comments ) ->
|
||||||
|
-- You can only edit comment text once comments have loaded
|
||||||
|
-- successfully, and when the comment is not currently
|
||||||
|
-- being submitted.
|
||||||
|
( { model | comments = Loaded ( Editing str, comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
ClickedPostComment cred slug ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( Editing "", comments ) ->
|
||||||
|
-- No posting empty comments!
|
||||||
|
-- We don't use Log.error here because this isn't an error,
|
||||||
|
-- it just doesn't do anything.
|
||||||
|
( model, Cmd.none )
|
||||||
|
|
||||||
|
Loaded ( Editing str, comments ) ->
|
||||||
|
( { model | comments = Loaded ( Sending str, comments ) }
|
||||||
|
, cred
|
||||||
|
|> Comment.post slug str
|
||||||
|
|> Http.send CompletedPostComment
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- Either we have no comment to post, or there's already
|
||||||
|
-- one in the process of being posted, or we don't have
|
||||||
|
-- a valid article, in which case how did we post this?
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedPostComment (Ok comment) ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( _, comments ) ->
|
||||||
|
( { model | comments = Loaded ( Editing "", comment :: comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedPostComment (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedDeleteComment cred slug id ->
|
||||||
|
( model
|
||||||
|
, cred
|
||||||
|
|> Comment.delete slug id
|
||||||
|
|> Http.send (CompletedDeleteComment id)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedDeleteComment id (Ok ()) ->
|
||||||
|
case model.comments of
|
||||||
|
Loaded ( commentText, comments ) ->
|
||||||
|
( { model | comments = Loaded ( commentText, withoutComment id comments ) }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
( model, Log.error )
|
||||||
|
|
||||||
|
CompletedDeleteComment id (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
ClickedDeleteArticle cred slug ->
|
||||||
|
( model
|
||||||
|
, delete slug cred
|
||||||
|
|> Http.send CompletedDeleteArticle
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedDeleteArticle (Ok ()) ->
|
||||||
|
( model, Route.replaceUrl (Session.navKey model.session) Route.Home )
|
||||||
|
|
||||||
|
CompletedDeleteArticle (Err error) ->
|
||||||
|
( { model | errors = Api.addServerError model.errors }
|
||||||
|
, Log.error
|
||||||
|
)
|
||||||
|
|
||||||
|
GotTimeZone tz ->
|
||||||
|
( { model | timeZone = tz }, Cmd.none )
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
article =
|
||||||
|
case model.article of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|
||||||
|
comments =
|
||||||
|
case model.comments of
|
||||||
|
Loading ->
|
||||||
|
LoadingSlowly
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | article = article, comments = comments }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
delete : Slug -> Cred -> Http.Request ()
|
||||||
|
delete slug cred =
|
||||||
|
Article.url slug []
|
||||||
|
|> HttpBuilder.delete
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Body -> Cmd Msg
|
||||||
|
fave toRequest cred slug body =
|
||||||
|
toRequest slug cred
|
||||||
|
|> Http.toTask
|
||||||
|
|> Task.map (Article.fromPreview body)
|
||||||
|
|> Task.attempt CompletedFavoriteChange
|
||||||
|
|
||||||
|
|
||||||
|
withoutComment : CommentId -> List Comment -> List Comment
|
||||||
|
withoutComment id list =
|
||||||
|
List.filter (\comment -> Comment.id comment /= id) list
|
||||||
|
|
||||||
|
|
||||||
|
favoriteButton : Cred -> Article Full -> Html Msg
|
||||||
|
favoriteButton cred article =
|
||||||
|
let
|
||||||
|
{ favoritesCount, favorited } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
slug =
|
||||||
|
Article.slug article
|
||||||
|
|
||||||
|
body =
|
||||||
|
Article.body article
|
||||||
|
|
||||||
|
kids =
|
||||||
|
[ text (" Favorite Article (" ++ String.fromInt favoritesCount ++ ")") ]
|
||||||
|
in
|
||||||
|
if favorited then
|
||||||
|
Article.unfavoriteButton cred (ClickedUnfavorite cred slug body) [] kids
|
||||||
|
|
||||||
|
else
|
||||||
|
Article.favoriteButton cred (ClickedFavorite cred slug body) [] kids
|
||||||
|
|
||||||
|
|
||||||
|
deleteButton : Cred -> Article a -> Html Msg
|
||||||
|
deleteButton cred article =
|
||||||
|
let
|
||||||
|
msg =
|
||||||
|
ClickedDeleteArticle cred (Article.slug article)
|
||||||
|
in
|
||||||
|
button [ class "btn btn-outline-danger btn-sm", onClick msg ]
|
||||||
|
[ i [ class "ion-trash-a" ] [], text " Delete Article" ]
|
||||||
|
|
||||||
|
|
||||||
|
editButton : Article a -> Html Msg
|
||||||
|
editButton article =
|
||||||
|
a [ class "btn btn-outline-secondary btn-sm", Route.href (Route.EditArticle (Article.slug article)) ]
|
||||||
|
[ i [ class "ion-edit" ] [], text " Edit Article" ]
|
||||||
@@ -0,0 +1,620 @@
|
|||||||
|
module Page.Article.Editor exposing (Model, Msg, initEdit, initNew, subscriptions, toSession, update, view)
|
||||||
|
|
||||||
|
import Api
|
||||||
|
import Article exposing (Article, Full)
|
||||||
|
import Article.Body exposing (Body)
|
||||||
|
import Article.Slug as Slug exposing (Slug)
|
||||||
|
import Browser.Navigation as Nav
|
||||||
|
import Html exposing (..)
|
||||||
|
import Html.Attributes exposing (attribute, class, disabled, href, id, placeholder, type_, value)
|
||||||
|
import Html.Events exposing (onInput, onSubmit)
|
||||||
|
import Http
|
||||||
|
import HttpBuilder exposing (withBody, withExpect)
|
||||||
|
import Json.Decode as Decode
|
||||||
|
import Json.Encode as Encode
|
||||||
|
import Loading
|
||||||
|
import Page
|
||||||
|
import Profile exposing (Profile)
|
||||||
|
import Route
|
||||||
|
import Session exposing (Session)
|
||||||
|
import Task exposing (Task)
|
||||||
|
import Time
|
||||||
|
import Viewer exposing (Viewer)
|
||||||
|
import Viewer.Cred as Cred exposing (Cred)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- MODEL
|
||||||
|
|
||||||
|
|
||||||
|
type alias Model =
|
||||||
|
{ session : Session
|
||||||
|
, status : Status
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type
|
||||||
|
Status
|
||||||
|
-- Edit Article
|
||||||
|
= Loading Slug
|
||||||
|
| LoadingSlowly Slug
|
||||||
|
| LoadingFailed Slug
|
||||||
|
| Saving Slug Form
|
||||||
|
| Editing Slug (List Problem) Form
|
||||||
|
-- New Article
|
||||||
|
| EditingNew (List Problem) Form
|
||||||
|
| Creating Form
|
||||||
|
|
||||||
|
|
||||||
|
type Problem
|
||||||
|
= InvalidEntry ValidatedField String
|
||||||
|
| ServerError String
|
||||||
|
|
||||||
|
|
||||||
|
type alias Form =
|
||||||
|
{ title : String
|
||||||
|
, body : String
|
||||||
|
, description : String
|
||||||
|
, tags : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
initNew : Session -> ( Model, Cmd msg )
|
||||||
|
initNew session =
|
||||||
|
( { session = session
|
||||||
|
, status =
|
||||||
|
EditingNew []
|
||||||
|
{ title = ""
|
||||||
|
, body = ""
|
||||||
|
, description = ""
|
||||||
|
, tags = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
initEdit : Session -> Slug -> ( Model, Cmd Msg )
|
||||||
|
initEdit session slug =
|
||||||
|
( { session = session
|
||||||
|
, status = Loading slug
|
||||||
|
}
|
||||||
|
, Cmd.batch
|
||||||
|
[ Article.fetch (Session.cred session) slug
|
||||||
|
|> Http.toTask
|
||||||
|
-- If init fails, store the slug that failed in the msg, so we can
|
||||||
|
-- at least have it later to display the page's title properly!
|
||||||
|
|> Task.mapError (\httpError -> ( slug, httpError ))
|
||||||
|
|> Task.attempt CompletedArticleLoad
|
||||||
|
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- VIEW
|
||||||
|
|
||||||
|
|
||||||
|
view : Model -> { title : String, content : Html Msg }
|
||||||
|
view model =
|
||||||
|
{ title =
|
||||||
|
case getSlug model.status of
|
||||||
|
Just slug ->
|
||||||
|
"Edit Article - " ++ Slug.toString slug
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
"New Article"
|
||||||
|
, content =
|
||||||
|
case Session.cred model.session of
|
||||||
|
Just cred ->
|
||||||
|
viewAuthenticated cred model
|
||||||
|
|
||||||
|
Nothing ->
|
||||||
|
text "Sign in to edit this article."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
viewProblems : List Problem -> Html msg
|
||||||
|
viewProblems problems =
|
||||||
|
ul [ class "error-messages" ]
|
||||||
|
(List.map viewProblem problems)
|
||||||
|
|
||||||
|
|
||||||
|
viewProblem : Problem -> Html msg
|
||||||
|
viewProblem problem =
|
||||||
|
let
|
||||||
|
errorMessage =
|
||||||
|
case problem of
|
||||||
|
InvalidEntry _ message ->
|
||||||
|
message
|
||||||
|
|
||||||
|
ServerError message ->
|
||||||
|
message
|
||||||
|
in
|
||||||
|
li [] [ text errorMessage ]
|
||||||
|
|
||||||
|
|
||||||
|
viewAuthenticated : Cred -> Model -> Html Msg
|
||||||
|
viewAuthenticated cred model =
|
||||||
|
let
|
||||||
|
formHtml =
|
||||||
|
case model.status of
|
||||||
|
Loading _ ->
|
||||||
|
[]
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
[ Loading.icon ]
|
||||||
|
|
||||||
|
Saving slug form ->
|
||||||
|
[ viewForm cred form (editArticleSaveButton [ disabled True ]) ]
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
[ viewForm cred form (newArticleSaveButton [ disabled True ]) ]
|
||||||
|
|
||||||
|
Editing slug problems form ->
|
||||||
|
[ viewProblems problems
|
||||||
|
, viewForm cred form (editArticleSaveButton [])
|
||||||
|
]
|
||||||
|
|
||||||
|
EditingNew problems form ->
|
||||||
|
[ viewProblems problems
|
||||||
|
, viewForm cred form (newArticleSaveButton [])
|
||||||
|
]
|
||||||
|
|
||||||
|
LoadingFailed _ ->
|
||||||
|
[ text "Article failed to load." ]
|
||||||
|
in
|
||||||
|
div [ class "editor-page" ]
|
||||||
|
[ div [ class "container page" ]
|
||||||
|
[ div [ class "row" ]
|
||||||
|
[ div [ class "col-md-10 offset-md-1 col-xs-12" ]
|
||||||
|
formHtml
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
viewForm : Cred -> Form -> Html Msg -> Html Msg
|
||||||
|
viewForm cred fields saveButton =
|
||||||
|
Html.form [ onSubmit (ClickedSave cred) ]
|
||||||
|
[ fieldset []
|
||||||
|
[ fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control form-control-lg"
|
||||||
|
, placeholder "Article Title"
|
||||||
|
, onInput EnteredTitle
|
||||||
|
, value fields.title
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "What's this article about?"
|
||||||
|
, onInput EnteredDescription
|
||||||
|
, value fields.description
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ textarea
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Write your article (in markdown)"
|
||||||
|
, attribute "rows" "8"
|
||||||
|
, onInput EnteredBody
|
||||||
|
, value fields.body
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, fieldset [ class "form-group" ]
|
||||||
|
[ input
|
||||||
|
[ class "form-control"
|
||||||
|
, placeholder "Enter tags"
|
||||||
|
, onInput EnteredTags
|
||||||
|
, value fields.tags
|
||||||
|
]
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
, saveButton
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
editArticleSaveButton : List (Attribute msg) -> Html msg
|
||||||
|
editArticleSaveButton extraAttrs =
|
||||||
|
saveArticleButton "Update Article" extraAttrs
|
||||||
|
|
||||||
|
|
||||||
|
newArticleSaveButton : List (Attribute msg) -> Html msg
|
||||||
|
newArticleSaveButton extraAttrs =
|
||||||
|
saveArticleButton "Publish Article" extraAttrs
|
||||||
|
|
||||||
|
|
||||||
|
saveArticleButton : String -> List (Attribute msg) -> Html msg
|
||||||
|
saveArticleButton caption extraAttrs =
|
||||||
|
button (class "btn btn-lg pull-xs-right btn-primary" :: extraAttrs)
|
||||||
|
[ text caption ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- UPDATE
|
||||||
|
|
||||||
|
|
||||||
|
type Msg
|
||||||
|
= ClickedSave Cred
|
||||||
|
| EnteredBody String
|
||||||
|
| EnteredDescription String
|
||||||
|
| EnteredTags String
|
||||||
|
| EnteredTitle String
|
||||||
|
| CompletedCreate (Result Http.Error (Article Full))
|
||||||
|
| CompletedEdit (Result Http.Error (Article Full))
|
||||||
|
| CompletedArticleLoad (Result ( Slug, Http.Error ) (Article Full))
|
||||||
|
| GotSession Session
|
||||||
|
| PassedSlowLoadThreshold
|
||||||
|
|
||||||
|
|
||||||
|
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||||
|
update msg model =
|
||||||
|
case msg of
|
||||||
|
ClickedSave cred ->
|
||||||
|
model.status
|
||||||
|
|> save cred
|
||||||
|
|> Tuple.mapFirst (\status -> { model | status = status })
|
||||||
|
|
||||||
|
EnteredTitle title ->
|
||||||
|
updateForm (\form -> { form | title = title }) model
|
||||||
|
|
||||||
|
EnteredDescription description ->
|
||||||
|
updateForm (\form -> { form | description = description }) model
|
||||||
|
|
||||||
|
EnteredTags tags ->
|
||||||
|
updateForm (\form -> { form | tags = tags }) model
|
||||||
|
|
||||||
|
EnteredBody body ->
|
||||||
|
updateForm (\form -> { form | body = body }) model
|
||||||
|
|
||||||
|
CompletedCreate (Ok article) ->
|
||||||
|
( model
|
||||||
|
, Route.Article (Article.slug article)
|
||||||
|
|> Route.replaceUrl (Session.navKey model.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedCreate (Err error) ->
|
||||||
|
( { model | status = savingError error model.status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedEdit (Ok article) ->
|
||||||
|
( model
|
||||||
|
, Route.Article (Article.slug article)
|
||||||
|
|> Route.replaceUrl (Session.navKey model.session)
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedEdit (Err error) ->
|
||||||
|
( { model | status = savingError error model.status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedArticleLoad (Err ( slug, error )) ->
|
||||||
|
( { model | status = LoadingFailed slug }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
CompletedArticleLoad (Ok article) ->
|
||||||
|
let
|
||||||
|
{ title, description, tags } =
|
||||||
|
Article.metadata article
|
||||||
|
|
||||||
|
status =
|
||||||
|
Editing (Article.slug article)
|
||||||
|
[]
|
||||||
|
{ title = title
|
||||||
|
, body = Article.Body.toMarkdownString (Article.body article)
|
||||||
|
, description = description
|
||||||
|
, tags = String.join " " tags
|
||||||
|
}
|
||||||
|
in
|
||||||
|
( { model | status = status }
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
GotSession session ->
|
||||||
|
( { model | session = session }
|
||||||
|
, Route.replaceUrl (Session.navKey session) Route.Home
|
||||||
|
)
|
||||||
|
|
||||||
|
PassedSlowLoadThreshold ->
|
||||||
|
let
|
||||||
|
-- If any data is still Loading, change it to LoadingSlowly
|
||||||
|
-- so `view` knows to render a spinner.
|
||||||
|
status =
|
||||||
|
case model.status of
|
||||||
|
Loading slug ->
|
||||||
|
LoadingSlowly slug
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
in
|
||||||
|
( { model | status = status }, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
save : Cred -> Status -> ( Status, Cmd Msg )
|
||||||
|
save cred status =
|
||||||
|
case status of
|
||||||
|
Editing slug _ form ->
|
||||||
|
case validate form of
|
||||||
|
Ok validForm ->
|
||||||
|
( Saving slug form
|
||||||
|
, edit slug validForm cred
|
||||||
|
|> Http.send CompletedEdit
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( Editing slug problems form
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
EditingNew _ form ->
|
||||||
|
case validate form of
|
||||||
|
Ok validForm ->
|
||||||
|
( Creating form
|
||||||
|
, create validForm cred
|
||||||
|
|> Http.send CompletedCreate
|
||||||
|
)
|
||||||
|
|
||||||
|
Err problems ->
|
||||||
|
( EditingNew problems form
|
||||||
|
, Cmd.none
|
||||||
|
)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
-- We're in a state where saving is not allowed.
|
||||||
|
-- We tried to prevent getting here by disabling the Save
|
||||||
|
-- button, but somehow the user got here anyway!
|
||||||
|
--
|
||||||
|
-- If we had an error logging service, we would send
|
||||||
|
-- something to it here!
|
||||||
|
( status, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
savingError : Http.Error -> Status -> Status
|
||||||
|
savingError error status =
|
||||||
|
let
|
||||||
|
problems =
|
||||||
|
[ ServerError "Error saving article" ]
|
||||||
|
in
|
||||||
|
case status of
|
||||||
|
Saving slug form ->
|
||||||
|
Editing slug problems form
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
EditingNew problems form
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
status
|
||||||
|
|
||||||
|
|
||||||
|
{-| Helper function for `update`. Updates the form, if there is one,
|
||||||
|
and returns Cmd.none.
|
||||||
|
|
||||||
|
Useful for recording form fields!
|
||||||
|
|
||||||
|
This could also log errors to the server if we are trying to record things in
|
||||||
|
the form and we don't actually have a form.
|
||||||
|
|
||||||
|
-}
|
||||||
|
updateForm : (Form -> Form) -> Model -> ( Model, Cmd Msg )
|
||||||
|
updateForm transform model =
|
||||||
|
let
|
||||||
|
newModel =
|
||||||
|
case model.status of
|
||||||
|
Loading _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
LoadingSlowly _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
LoadingFailed _ ->
|
||||||
|
model
|
||||||
|
|
||||||
|
Saving slug form ->
|
||||||
|
{ model | status = Saving slug (transform form) }
|
||||||
|
|
||||||
|
Editing slug errors form ->
|
||||||
|
{ model | status = Editing slug errors (transform form) }
|
||||||
|
|
||||||
|
EditingNew errors form ->
|
||||||
|
{ model | status = EditingNew errors (transform form) }
|
||||||
|
|
||||||
|
Creating form ->
|
||||||
|
{ model | status = Creating (transform form) }
|
||||||
|
in
|
||||||
|
( newModel, Cmd.none )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- SUBSCRIPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
subscriptions : Model -> Sub Msg
|
||||||
|
subscriptions model =
|
||||||
|
Session.changes GotSession (Session.navKey model.session)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- FORM
|
||||||
|
|
||||||
|
|
||||||
|
{-| Marks that we've trimmed the form's fields, so we don't accidentally send
|
||||||
|
it to the server without having trimmed it!
|
||||||
|
-}
|
||||||
|
type TrimmedForm
|
||||||
|
= Trimmed Form
|
||||||
|
|
||||||
|
|
||||||
|
{-| When adding a variant here, add it to `fieldsToValidate` too!
|
||||||
|
-}
|
||||||
|
type ValidatedField
|
||||||
|
= Title
|
||||||
|
| Body
|
||||||
|
|
||||||
|
|
||||||
|
fieldsToValidate : List ValidatedField
|
||||||
|
fieldsToValidate =
|
||||||
|
[ Title
|
||||||
|
, Body
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Trim the form and validate its fields. If there are problems, report them!
|
||||||
|
-}
|
||||||
|
validate : Form -> Result (List Problem) TrimmedForm
|
||||||
|
validate form =
|
||||||
|
let
|
||||||
|
trimmedForm =
|
||||||
|
trimFields form
|
||||||
|
in
|
||||||
|
case List.concatMap (validateField trimmedForm) fieldsToValidate of
|
||||||
|
[] ->
|
||||||
|
Ok trimmedForm
|
||||||
|
|
||||||
|
problems ->
|
||||||
|
Err problems
|
||||||
|
|
||||||
|
|
||||||
|
validateField : TrimmedForm -> ValidatedField -> List Problem
|
||||||
|
validateField (Trimmed form) field =
|
||||||
|
List.map (InvalidEntry field) <|
|
||||||
|
case field of
|
||||||
|
Title ->
|
||||||
|
if String.isEmpty form.title then
|
||||||
|
[ "title can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
Body ->
|
||||||
|
if String.isEmpty form.body then
|
||||||
|
[ "body can't be blank." ]
|
||||||
|
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
|
||||||
|
|
||||||
|
{-| Don't trim while the user is typing! That would be super annoying.
|
||||||
|
Instead, trim only on submit.
|
||||||
|
-}
|
||||||
|
trimFields : Form -> TrimmedForm
|
||||||
|
trimFields form =
|
||||||
|
Trimmed
|
||||||
|
{ title = String.trim form.title
|
||||||
|
, body = String.trim form.body
|
||||||
|
, description = String.trim form.description
|
||||||
|
, tags = String.trim form.tags
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- HTTP
|
||||||
|
|
||||||
|
|
||||||
|
create : TrimmedForm -> Cred -> Http.Request (Article Full)
|
||||||
|
create (Trimmed form) cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
Article.fullDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
|
||||||
|
article =
|
||||||
|
Encode.object
|
||||||
|
[ ( "title", Encode.string form.title )
|
||||||
|
, ( "description", Encode.string form.description )
|
||||||
|
, ( "body", Encode.string form.body )
|
||||||
|
, ( "tagList", Encode.list Encode.string (tagsFromString form.tags) )
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonBody =
|
||||||
|
Encode.object [ ( "article", article ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Api.url [ "articles" ]
|
||||||
|
|> HttpBuilder.post
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withBody jsonBody
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
tagsFromString : String -> List String
|
||||||
|
tagsFromString str =
|
||||||
|
String.split " " str
|
||||||
|
|> List.map String.trim
|
||||||
|
|> List.filter (not << String.isEmpty)
|
||||||
|
|
||||||
|
|
||||||
|
edit : Slug -> TrimmedForm -> Cred -> Http.Request (Article Full)
|
||||||
|
edit articleSlug (Trimmed form) cred =
|
||||||
|
let
|
||||||
|
expect =
|
||||||
|
Article.fullDecoder (Just cred)
|
||||||
|
|> Decode.field "article"
|
||||||
|
|> Http.expectJson
|
||||||
|
|
||||||
|
article =
|
||||||
|
Encode.object
|
||||||
|
[ ( "title", Encode.string form.title )
|
||||||
|
, ( "description", Encode.string form.description )
|
||||||
|
, ( "body", Encode.string form.body )
|
||||||
|
]
|
||||||
|
|
||||||
|
jsonBody =
|
||||||
|
Encode.object [ ( "article", article ) ]
|
||||||
|
|> Http.jsonBody
|
||||||
|
in
|
||||||
|
Article.url articleSlug []
|
||||||
|
|> HttpBuilder.put
|
||||||
|
|> Cred.addHeader cred
|
||||||
|
|> withBody jsonBody
|
||||||
|
|> withExpect expect
|
||||||
|
|> HttpBuilder.toRequest
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- EXPORT
|
||||||
|
|
||||||
|
|
||||||
|
toSession : Model -> Session
|
||||||
|
toSession model =
|
||||||
|
model.session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
-- INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
{-| Used for setting the page's title.
|
||||||
|
-}
|
||||||
|
getSlug : Status -> Maybe Slug
|
||||||
|
getSlug status =
|
||||||
|
case status of
|
||||||
|
Loading slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
LoadingSlowly slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
LoadingFailed slug ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
Saving slug _ ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
Editing slug _ _ ->
|
||||||
|
Just slug
|
||||||
|
|
||||||
|
EditingNew _ _ ->
|
||||||
|
Nothing
|
||||||
|
|
||||||
|
Creating _ ->
|
||||||
|
Nothing
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module Page.Blank exposing (view)
|
||||||
|
|
||||||
|
import Html exposing (Html)
|
||||||
|
|
||||||
|
|
||||||
|
view : { title : String, content : Html msg }
|
||||||
|
view =
|
||||||
|
{ title = ""
|
||||||
|
, content = Html.text ""
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user