From 46d8a711d5ee3068f002f0483b0d0e9035b048b9 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 5 Aug 2018 07:57:52 -0400 Subject: [PATCH] Add part3 --- advanced/part3/README.md | 16 + advanced/part3/elm.json | 32 ++ advanced/part3/src/Api.elm | 51 ++ advanced/part3/src/Article.elm | 321 ++++++++++++ advanced/part3/src/Article/Body.elm | 38 ++ advanced/part3/src/Article/Comment.elm | 139 +++++ advanced/part3/src/Article/Feed.elm | 443 ++++++++++++++++ advanced/part3/src/Article/FeedSources.elm | 109 ++++ advanced/part3/src/Article/Preview.elm | 72 +++ advanced/part3/src/Article/Slug.elm | 35 ++ advanced/part3/src/Article/Tag.elm | 41 ++ advanced/part3/src/Assets.elm | 33 ++ advanced/part3/src/Author.elm | 251 +++++++++ advanced/part3/src/Avatar.elm | 66 +++ advanced/part3/src/CommentId.elm | 29 ++ advanced/part3/src/Email.elm | 45 ++ advanced/part3/src/Loading.elm | 21 + advanced/part3/src/Log.elm | 20 + advanced/part3/src/Main.elm | 336 ++++++++++++ advanced/part3/src/Page.elm | 159 ++++++ advanced/part3/src/Page/Article.elm | 574 +++++++++++++++++++++ advanced/part3/src/Page/Article/Editor.elm | 526 +++++++++++++++++++ advanced/part3/src/Page/Blank.elm | 10 + advanced/part3/src/Page/Home.elm | 230 +++++++++ advanced/part3/src/Page/Login.elm | 259 ++++++++++ advanced/part3/src/Page/NotFound.elm | 21 + advanced/part3/src/Page/Profile.elm | 309 +++++++++++ advanced/part3/src/Page/Register.elm | 266 ++++++++++ advanced/part3/src/Page/Settings.elm | 374 ++++++++++++++ advanced/part3/src/PaginatedList.elm | 98 ++++ advanced/part3/src/Profile.elm | 56 ++ advanced/part3/src/Route.elm | 107 ++++ advanced/part3/src/Session.elm | 98 ++++ advanced/part3/src/Timestamp.elm | 100 ++++ advanced/part3/src/Username.elm | 47 ++ advanced/part3/src/Viewer.elm | 71 +++ advanced/part3/src/Viewer/Cred.elm | 53 ++ 37 files changed, 5456 insertions(+) create mode 100644 advanced/part3/README.md create mode 100644 advanced/part3/elm.json create mode 100644 advanced/part3/src/Api.elm create mode 100644 advanced/part3/src/Article.elm create mode 100644 advanced/part3/src/Article/Body.elm create mode 100644 advanced/part3/src/Article/Comment.elm create mode 100644 advanced/part3/src/Article/Feed.elm create mode 100644 advanced/part3/src/Article/FeedSources.elm create mode 100644 advanced/part3/src/Article/Preview.elm create mode 100644 advanced/part3/src/Article/Slug.elm create mode 100644 advanced/part3/src/Article/Tag.elm create mode 100644 advanced/part3/src/Assets.elm create mode 100644 advanced/part3/src/Author.elm create mode 100644 advanced/part3/src/Avatar.elm create mode 100644 advanced/part3/src/CommentId.elm create mode 100644 advanced/part3/src/Email.elm create mode 100644 advanced/part3/src/Loading.elm create mode 100644 advanced/part3/src/Log.elm create mode 100644 advanced/part3/src/Main.elm create mode 100644 advanced/part3/src/Page.elm create mode 100644 advanced/part3/src/Page/Article.elm create mode 100644 advanced/part3/src/Page/Article/Editor.elm create mode 100644 advanced/part3/src/Page/Blank.elm create mode 100644 advanced/part3/src/Page/Home.elm create mode 100644 advanced/part3/src/Page/Login.elm create mode 100644 advanced/part3/src/Page/NotFound.elm create mode 100644 advanced/part3/src/Page/Profile.elm create mode 100644 advanced/part3/src/Page/Register.elm create mode 100644 advanced/part3/src/Page/Settings.elm create mode 100644 advanced/part3/src/PaginatedList.elm create mode 100644 advanced/part3/src/Profile.elm create mode 100644 advanced/part3/src/Route.elm create mode 100644 advanced/part3/src/Session.elm create mode 100644 advanced/part3/src/Timestamp.elm create mode 100644 advanced/part3/src/Username.elm create mode 100644 advanced/part3/src/Viewer.elm create mode 100644 advanced/part3/src/Viewer/Cred.elm diff --git a/advanced/part3/README.md b/advanced/part3/README.md new file mode 100644 index 0000000..5f79041 --- /dev/null +++ b/advanced/part3/README.md @@ -0,0 +1,16 @@ +# Part 3 + +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 + +Resolve the TODOs in these files: +* `src/Page/Home.elm` +* `src/Page/Settings.elm` +* `src/Page/Article.elm` diff --git a/advanced/part3/elm.json b/advanced/part3/elm.json new file mode 100644 index 0000000..ff88284 --- /dev/null +++ b/advanced/part3/elm.json @@ -0,0 +1,32 @@ +{ + "type": "application", + "source-directories": [ + "src" + ], + "elm-version": "0.19.0", + "dependencies": { + "direct": { + "NoRedInk/json-decode-pipeline": "1.0.0", + "elm/browser": "1.0.0", + "elm/core": "1.0.0", + "elm/html": "1.0.0", + "elm/http": "1.0.0", + "elm/json": "1.0.0", + "elm/time": "1.0.0", + "elm/url": "1.0.0", + "elm-explorations/markdown": "1.0.0", + "lukewestby/elm-http-builder": "6.0.0", + "rtfeldman/elm-iso8601": "1.0.1", + "rtfeldman/elm-validate": "4.0.0" + }, + "indirect": { + "elm/parser": "1.0.0", + "elm/regex": "1.0.0", + "elm/virtual-dom": "1.0.0" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/advanced/part3/src/Api.elm b/advanced/part3/src/Api.elm new file mode 100644 index 0000000..2ac50a0 --- /dev/null +++ b/advanced/part3/src/Api.elm @@ -0,0 +1,51 @@ +module Api exposing (addServerError, listErrors, optionalError, 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. +-} +listErrors : Decoder (List String) -> Http.Error -> List String +listErrors decoder error = + case error of + Http.BadStatus response -> + response.body + |> decodeString (field "errors" decoder) + |> Result.withDefault [ "Server error" ] + + err -> + [ "Server error" ] + + +optionalError : String -> Decoder (List String -> a) -> Decoder a +optionalError fieldName = + let + errorToString errorMessage = + String.join " " [ fieldName, errorMessage ] + in + optional fieldName (Decode.list (Decode.map errorToString string)) [] diff --git a/advanced/part3/src/Article.elm b/advanced/part3/src/Article.elm new file mode 100644 index 0000000..b5c550a --- /dev/null +++ b/advanced/part3/src/Article.elm @@ -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 - 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) diff --git a/advanced/part3/src/Article/Body.elm b/advanced/part3/src/Article/Body.elm new file mode 100644 index 0000000..b1c55f1 --- /dev/null +++ b/advanced/part3/src/Article/Body.elm @@ -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 diff --git a/advanced/part3/src/Article/Comment.elm b/advanced/part3/src/Article/Comment.elm new file mode 100644 index 0000000..5517a30 --- /dev/null +++ b/advanced/part3/src/Article/Comment.elm @@ -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) diff --git a/advanced/part3/src/Article/Feed.elm b/advanced/part3/src/Article/Feed.elm new file mode 100644 index 0000000..563c7cf --- /dev/null +++ b/advanced/part3/src/Article/Feed.elm @@ -0,0 +1,443 @@ +module Article.Feed + exposing + ( FeedConfig + , ListConfig + , Model + , Msg + , defaultFeedConfig + , defaultListConfig + , init + , selectTag + , update + , viewArticles + , viewFeedSources + ) + +import Api +import Article exposing (Article, Preview) +import Article.FeedSources as FeedSources exposing (FeedSources, Source(..)) +import Article.Preview +import Article.Slug as ArticleSlug exposing (Slug) +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, src) +import Html.Events exposing (onClick) +import Http +import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams) +import Json.Decode as Decode exposing (Decoder) +import Json.Decode.Pipeline exposing (required) +import Page +import PaginatedList exposing (PaginatedList) +import Profile +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) + + +{-| NOTE: This module has its own Model, view, and update. This is not normal! +If you find yourself doing this often, please watch + +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 InternalModel + + +{-| 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 InternalModel = + { session : Session + , errors : List String + , articles : PaginatedList (Article Preview) + , sources : FeedSources + , isLoading : Bool + } + + +init : Session -> FeedSources -> Task Http.Error Model +init session sources = + let + fromArticles articles = + Model + { session = session + , errors = [] + , articles = articles + , sources = sources + , isLoading = False + } + in + FeedSources.selected sources + |> fetch (Session.cred session) 1 + |> Task.map fromArticles + + + +-- VIEW + + +viewArticles : Time.Zone -> Model -> List (Html Msg) +viewArticles timeZone (Model { articles, sources, session }) = + let + maybeCred = + Session.cred session + + articlesHtml = + PaginatedList.values articles + |> List.map (viewPreview maybeCred timeZone) + + feedSource = + FeedSources.selected sources + + pagination = + PaginatedList.view ClickedFeedPage articles (limit feedSource) + in + List.append articlesHtml [ pagination ] + + +viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg +viewPreview maybeCred timeZone article = + let + slug = + Article.slug article + + config = + case maybeCred of + Just cred -> + Just + { cred = cred + , favorite = ClickedFavorite cred slug + , unfavorite = ClickedUnfavorite cred slug + } + + Nothing -> + Nothing + in + Article.Preview.view config timeZone article + + +viewFeedSources : Model -> Html Msg +viewFeedSources (Model { sources, isLoading, errors }) = + let + errorsHtml = + Page.viewErrors ClickedDismissErrors errors + in + ul [ class "nav nav-pills outline-active" ] <| + List.concat + [ List.map (viewFeedSource False) (FeedSources.before sources) + , [ viewFeedSource True (FeedSources.selected sources) ] + , List.map (viewFeedSource False) (FeedSources.after sources) + , [ errorsHtml ] + ] + + +viewFeedSource : Bool -> Source -> Html Msg +viewFeedSource isSelected source = + li [ class "nav-item" ] + [ a + [ classList [ ( "nav-link", True ), ( "active", isSelected ) ] + , onClick (ClickedFeedSource source) + + -- The RealWorld CSS requires an href to work properly. + , href "" + ] + [ text (sourceName source) ] + ] + + +selectTag : Maybe Cred -> Tag -> Cmd Msg +selectTag maybeCred tag = + let + source = + TagFeed tag + in + fetch maybeCred 1 source + |> Task.attempt (CompletedFeedLoad source) + + +sourceName : Source -> String +sourceName source = + case source of + YourFeed _ -> + "Your Feed" + + GlobalFeed -> + "Global Feed" + + TagFeed tagName -> + "#" ++ Tag.toString tagName + + FavoritedFeed username -> + "Favorited Articles" + + AuthorFeed username -> + "My Articles" + + +limit : Source -> Int +limit feedSource = + case feedSource of + YourFeed _ -> + 10 + + GlobalFeed -> + 10 + + TagFeed tagName -> + 10 + + FavoritedFeed username -> + 5 + + AuthorFeed username -> + 5 + + + +-- UPDATE + + +type Msg + = ClickedDismissErrors + | ClickedFavorite Cred Slug + | ClickedUnfavorite Cred Slug + | ClickedFeedPage Int + | ClickedFeedSource Source + | CompletedFavorite (Result Http.Error (Article Preview)) + | CompletedFeedLoad Source (Result Http.Error (PaginatedList (Article Preview))) + + +update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg ) +update maybeCred msg (Model model) = + case msg of + ClickedDismissErrors -> + ( Model { model | errors = [] }, Cmd.none ) + + ClickedFeedSource source -> + ( Model { model | isLoading = True } + , source + |> fetch maybeCred 1 + |> Task.attempt (CompletedFeedLoad source) + ) + + CompletedFeedLoad source (Ok articles) -> + ( Model + { model + | articles = articles + , sources = FeedSources.select source model.sources + , isLoading = False + } + , Cmd.none + ) + + CompletedFeedLoad _ (Err error) -> + ( Model + { model + | errors = Api.addServerError model.errors + , isLoading = False + } + , 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 + ) + + ClickedFeedPage page -> + let + source = + FeedSources.selected model.sources + in + ( Model model + , fetch maybeCred page source + |> Task.andThen (\articles -> Task.map (\_ -> articles) scrollToTop) + |> Task.attempt (CompletedFeedLoad source) + ) + + +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 ()) + + +fetch : Maybe Cred -> Int -> Source -> Task Http.Error (PaginatedList (Article Preview)) +fetch maybeCred page feedSource = + let + articlesPerPage = + limit feedSource + + offset = + (page - 1) * articlesPerPage + + listConfig = + { defaultListConfig | offset = offset, limit = articlesPerPage } + in + Task.map (PaginatedList.mapPage (\_ -> page)) <| + case feedSource of + YourFeed cred -> + let + feedConfig = + { defaultFeedConfig | offset = offset, limit = articlesPerPage } + in + feed feedConfig cred + |> Http.toTask + + GlobalFeed -> + list listConfig maybeCred + |> Http.toTask + + TagFeed tagName -> + list { listConfig | tag = Just tagName } maybeCred + |> Http.toTask + + FavoritedFeed username -> + list { listConfig | favorited = Just username } maybeCred + |> Http.toTask + + AuthorFeed username -> + list { listConfig | author = Just username } maybeCred + |> Http.toTask + + +replaceArticle : Article a -> Article a -> Article a +replaceArticle newArticle oldArticle = + if Article.slug newArticle == Article.slug oldArticle then + newArticle + + else + oldArticle + + + +-- LIST + + +type alias ListConfig = + { tag : Maybe Tag + , author : Maybe Username + , favorited : Maybe Username + , limit : Int + , offset : Int + } + + +defaultListConfig : ListConfig +defaultListConfig = + { tag = Nothing + , author = Nothing + , favorited = Nothing + , limit = 20 + , offset = 0 + } + + +list : ListConfig -> Maybe Cred -> Http.Request (PaginatedList (Article Preview)) +list config maybeCred = + [ Maybe.map (\tag -> ( "tag", Tag.toString tag )) config.tag + , Maybe.map (\author -> ( "author", Username.toString author )) config.author + , Maybe.map (\favorited -> ( "favorited", Username.toString favorited )) config.favorited + , Just ( "limit", String.fromInt config.limit ) + , Just ( "offset", String.fromInt config.offset ) + ] + |> List.filterMap identity + |> buildFromQueryParams maybeCred (Api.url [ "articles" ]) + |> Cred.addHeaderIfAvailable maybeCred + |> HttpBuilder.toRequest + + + +-- FEED + + +type alias FeedConfig = + { limit : Int + , offset : Int + } + + +defaultFeedConfig : FeedConfig +defaultFeedConfig = + { limit = 10 + , offset = 0 + } + + +feed : FeedConfig -> Cred -> Http.Request (PaginatedList (Article Preview)) +feed config cred = + [ ( "limit", String.fromInt config.limit ) + , ( "offset", String.fromInt config.offset ) + ] + |> buildFromQueryParams (Just cred) (Api.url [ "articles", "feed" ]) + |> Cred.addHeader cred + |> HttpBuilder.toRequest + + + +-- SERIALIZATION + + +decoder : Maybe Cred -> Decoder (PaginatedList (Article Preview)) +decoder maybeCred = + Decode.succeed PaginatedList.fromList + |> required "articlesCount" Decode.int + |> required "articles" (Decode.list (Article.previewDecoder maybeCred)) + + + +-- REQUEST + + +buildFromQueryParams : Maybe Cred -> String -> List ( String, String ) -> RequestBuilder (PaginatedList (Article Preview)) +buildFromQueryParams maybeCred url queryParams = + HttpBuilder.get url + |> withExpect (Http.expectJson (decoder maybeCred)) + |> withQueryParams queryParams + + + +-- INTERNAL + + +fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> InternalModel -> ( Model, Cmd Msg ) +fave toRequest cred slug model = + ( Model model + , toRequest slug cred + |> Http.toTask + |> Task.attempt CompletedFavorite + ) diff --git a/advanced/part3/src/Article/FeedSources.elm b/advanced/part3/src/Article/FeedSources.elm new file mode 100644 index 0000000..cef87eb --- /dev/null +++ b/advanced/part3/src/Article/FeedSources.elm @@ -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 diff --git a/advanced/part3/src/Article/Preview.elm b/advanced/part3/src/Article/Preview.elm new file mode 100644 index 0000000..e51372a --- /dev/null +++ b/advanced/part3/src/Article/Preview.elm @@ -0,0 +1,72 @@ +module Article.Preview exposing (view) + +{-| A preview of an individual article, excluding its body. +-} + +import Article exposing (Article) +import Author +import Avatar exposing (Avatar) +import Html exposing (..) +import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src) +import Profile +import Route exposing (Route) +import Time +import Timestamp +import Viewer.Cred exposing (Cred) + + + +-- VIEW + + +view : Maybe { cred : Cred, favorite : msg, unfavorite : msg } -> Time.Zone -> Article a -> Html msg +view config timeZone article = + let + { title, description, createdAt } = + Article.metadata article + + author = + Article.author article + + profile = + Author.profile author + + username = + Author.username author + + faveButton = + case config of + Just { favorite, unfavorite, cred } -> + let + { favoritesCount, favorited } = + Article.metadata article + + viewButton = + if favorited then + Article.unfavoriteButton cred unfavorite + + else + Article.favoriteButton cred favorite + 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..." ] + ] + ] diff --git a/advanced/part3/src/Article/Slug.elm b/advanced/part3/src/Article/Slug.elm new file mode 100644 index 0000000..723f5f9 --- /dev/null +++ b/advanced/part3/src/Article/Slug.elm @@ -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 diff --git a/advanced/part3/src/Article/Tag.elm b/advanced/part3/src/Article/Tag.elm new file mode 100644 index 0000000..878733d --- /dev/null +++ b/advanced/part3/src/Article/Tag.elm @@ -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 diff --git a/advanced/part3/src/Assets.elm b/advanced/part3/src/Assets.elm new file mode 100644 index 0000000..7ad57fb --- /dev/null +++ b/advanced/part3/src/Assets.elm @@ -0,0 +1,33 @@ +module Assets exposing (error, 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 "/assets/images/error.jpg" + + + +-- USING IMAGES + + +src : Image -> Attribute msg +src (Image url) = + Attr.src url diff --git a/advanced/part3/src/Author.elm b/advanced/part3/src/Author.elm new file mode 100644 index 0000000..e8a3f91 --- /dev/null +++ b/advanced/part3/src/Author.elm @@ -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, 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 : (UnfollowedAuthor -> msg) -> UnfollowedAuthor -> Html msg +followButton toMsg ((UnfollowedAuthor uname _) as author) = + toggleFollowButton "Follow" + [ "btn-outline-secondary" ] + (toMsg author) + uname + + +unfollowButton : (FollowedAuthor -> msg) -> FollowedAuthor -> Html msg +unfollowButton toMsg ((FollowedAuthor uname _) as author) = + toggleFollowButton "Unfollow" + [ "btn-secondary" ] + (toMsg 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.field "following" Decode.bool + |> Decode.map (authorFromFollowing prof uname) + + +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 ] diff --git a/advanced/part3/src/Avatar.elm b/advanced/part3/src/Avatar.elm new file mode 100644 index 0000000..9317b79 --- /dev/null +++ b/advanced/part3/src/Avatar.elm @@ -0,0 +1,66 @@ +module Avatar exposing (Avatar, decoder, encode, src, toMaybeString) + +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) = + maybeUrl + |> Maybe.map Encode.string + |> Maybe.withDefault Encode.null + + +src : Avatar -> Attribute msg +src avatar = + Html.Attributes.src (avatarToUrl avatar) + + +toMaybeString : Avatar -> Maybe String +toMaybeString (Avatar maybeUrl) = + maybeUrl + + + +-- INTERNAL + + +avatarToUrl : Avatar -> String +avatarToUrl (Avatar maybeUrl) = + case maybeUrl of + Nothing -> + defaultPhotoUrl + + Just "" -> + defaultPhotoUrl + + Just url -> + url + + +defaultPhotoUrl : String +defaultPhotoUrl = + "http://localhost:3000/images/smiley-cyrus.jpg" diff --git a/advanced/part3/src/CommentId.elm b/advanced/part3/src/CommentId.elm new file mode 100644 index 0000000..f136e1b --- /dev/null +++ b/advanced/part3/src/CommentId.elm @@ -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 diff --git a/advanced/part3/src/Email.elm b/advanced/part3/src/Email.elm new file mode 100644 index 0000000..f696c01 --- /dev/null +++ b/advanced/part3/src/Email.elm @@ -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 diff --git a/advanced/part3/src/Loading.elm b/advanced/part3/src/Loading.elm new file mode 100644 index 0000000..42450c9 --- /dev/null +++ b/advanced/part3/src/Loading.elm @@ -0,0 +1,21 @@ +module Loading exposing (error, icon) + +{-| A loading spinner icon. +-} + +import Html exposing (Attribute, Html, div, li) +import Html.Attributes exposing (class, style) + + +icon : Html msg +icon = + li [ class "sk-three-bounce", style "float" "left", style "margin" "8px" ] + [ div [ class "sk-child sk-bounce1" ] [] + , div [ class "sk-child sk-bounce2" ] [] + , div [ class "sk-child sk-bounce3" ] [] + ] + + +error : String -> Html msg +error str = + Html.text ("Error loading " ++ str ++ ".") diff --git a/advanced/part3/src/Log.elm b/advanced/part3/src/Log.elm new file mode 100644 index 0000000..fe6111e --- /dev/null +++ b/advanced/part3/src/Log.elm @@ -0,0 +1,20 @@ +module Log exposing (error) + +{-| This is a placeholder API for how we might do logging through +some service like (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 diff --git a/advanced/part3/src/Main.elm b/advanced/part3/src/Main.elm new file mode 100644 index 0000000..054da57 --- /dev/null +++ b/advanced/part3/src/Main.elm @@ -0,0 +1,336 @@ +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 ViewingPage + = 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 + + +type alias Model = + { navKey : Nav.Key + , page : ViewingPage + } + + +init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg ) +init flags url navKey = + changeRouteTo (Route.fromUrl url) + { navKey = navKey + , page = 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)) page config + in + { title = title + , body = List.map (Html.map toMsg) body + } + in + case model.page 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 + + +toSession : ViewingPage -> 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.page + in + case maybeRoute of + Nothing -> + ( { model | page = NotFound session }, Cmd.none ) + + Just Route.Root -> + ( model, Route.replaceUrl model.navKey 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.page ) 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 model.navKey (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 + + ( _, _ ) -> + -- Disregard messages that arrived for the wrong page. + ( model, Cmd.none ) + + +updateWith : (subModel -> ViewingPage) -> (subMsg -> Msg) -> Model -> ( subModel, Cmd subMsg ) -> ( Model, Cmd Msg ) +updateWith toViewingPage toMsg model ( subModel, subCmd ) = + ( { model | page = toViewingPage subModel } + , Cmd.map toMsg subCmd + ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + case model.page of + NotFound _ -> + Sub.none + + Redirect _ -> + Sub.none + + 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 + } diff --git a/advanced/part3/src/Page.elm b/advanced/part3/src/Page.elm new file mode 100644 index 0000000..d60758e --- /dev/null +++ b/advanced/part3/src/Page.elm @@ -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" ] ] diff --git a/advanced/part3/src/Page/Article.elm b/advanced/part3/src/Page/Article.elm new file mode 100644 index 0000000..c169ee8 --- /dev/null +++ b/advanced/part3/src/Page/Article.elm @@ -0,0 +1,574 @@ +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.Preview +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 + | 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 + ] + ) + + + +-- VIEW + + +view : Model -> { title : String, content : Html Msg } +view model = + let + buttons = + viewButtons model + in + 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 + 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 -> + [ 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 = 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." + ] + + +{-| 👉 TODO refactor this to accept narrower types than the entire Model. + +💡 HINT: It may end up with multiple arguments! + +-} +viewButtons : Model -> List (Html Msg) +viewButtons model = + case Session.cred model.session of + Just cred -> + case model.article of + Loaded article -> + let + author = + Article.author article + in + 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 + ] + + Loading -> + [] + + Failed -> + [] + + Nothing -> + [] + + +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 + + +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 }, 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" ] diff --git a/advanced/part3/src/Page/Article/Editor.elm b/advanced/part3/src/Page/Article/Editor.elm new file mode 100644 index 0000000..960e0da --- /dev/null +++ b/advanced/part3/src/Page/Article/Editor.elm @@ -0,0 +1,526 @@ +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 Validate exposing (Valid, Validator, fromValid, ifBlank, validate) +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 + | LoadingFailed Slug + | Saving Slug Form + | Editing Slug (List Error) Form + -- New Article + | EditingNew (List Error) Form + | Creating Form + + +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 + } + , 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 + ) + + + +-- 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." + } + + +viewAuthenticated : Cred -> Model -> Html Msg +viewAuthenticated cred model = + let + formHtml = + case model.status of + Loading _ -> + [ Loading.icon ] + + Saving slug form -> + [ viewForm cred form (editArticleSaveButton [ disabled True ]) ] + + Creating form -> + [ viewForm cred form (newArticleSaveButton [ disabled True ]) ] + + Editing slug errors form -> + [ errors + |> List.map (\( _, error ) -> li [] [ text error ]) + |> ul [ class "error-messages" ] + , viewForm cred form (editArticleSaveButton []) + ] + + EditingNew errors form -> + [ errors + |> List.map (\( _, error ) -> li [] [ text error ]) + |> ul [ class "error-messages" ] + , 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 + + +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 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 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 }, Cmd.none ) + + +save : Cred -> Status -> ( Status, Cmd Msg ) +save cred status = + case status of + Editing slug _ fields -> + case validate formValidator fields of + Ok validForm -> + ( Saving slug fields + , edit slug validForm cred + |> Http.send CompletedEdit + ) + + Err errors -> + ( Editing slug errors fields + , Cmd.none + ) + + EditingNew _ fields -> + case validate formValidator fields of + Ok validForm -> + ( Creating fields + , create validForm cred + |> Http.send CompletedCreate + ) + + Err errors -> + ( EditingNew errors fields + , 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 : Status -> Status +savingError status = + let + errors = + [ ( Server, "Error saving article" ) ] + in + case status of + Saving slug form -> + Editing slug errors form + + Creating form -> + EditingNew errors 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 + + 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) + + + +-- VALIDATION + + +type ErrorSource + = Server + | Title + | Body + + +type alias Error = + ( ErrorSource, String ) + + +formValidator : Validator Error Form +formValidator = + Validate.all + [ ifBlank .title ( Title, "title can't be blank." ) + , ifBlank .body ( Body, "body can't be blank." ) + ] + + + +-- HTTP + + +create : Valid Form -> Cred -> Http.Request (Article Full) +create validForm cred = + let + form = + fromValid validForm + + 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 -> Valid Form -> Cred -> Http.Request (Article Full) +edit articleSlug validForm cred = + let + form = + fromValid validForm + + 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 + + LoadingFailed slug -> + Just slug + + Saving slug _ -> + Just slug + + Editing slug _ _ -> + Just slug + + EditingNew _ _ -> + Nothing + + Creating _ -> + Nothing diff --git a/advanced/part3/src/Page/Blank.elm b/advanced/part3/src/Page/Blank.elm new file mode 100644 index 0000000..3ae45a3 --- /dev/null +++ b/advanced/part3/src/Page/Blank.elm @@ -0,0 +1,10 @@ +module Page.Blank exposing (view) + +import Html exposing (Html) + + +view : { title : String, content : Html msg } +view = + { title = "" + , content = Html.text "" + } diff --git a/advanced/part3/src/Page/Home.elm b/advanced/part3/src/Page/Home.elm new file mode 100644 index 0000000..5025553 --- /dev/null +++ b/advanced/part3/src/Page/Home.elm @@ -0,0 +1,230 @@ +module Page.Home exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| The homepage. You can get here via either the / or /#/ routes. +-} + +import Article +import Article.Feed as Feed +import Article.FeedSources as FeedSources exposing (FeedSources, Source(..)) +import Article.Tag as Tag exposing (Tag) +import Html exposing (..) +import Html.Attributes exposing (attribute, class, classList, href, id, placeholder) +import Html.Events exposing (onClick) +import Http +import Loading +import Log +import Page +import Session exposing (Session) +import Task exposing (Task) +import Time +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , timeZone : Time.Zone + + -- Loaded independently from server + , tags : Status (List Tag) + , feed : Status Feed.Model + } + + +type Status a + = Loading + | Loaded a + | Failed + + +init : Session -> ( Model, Cmd Msg ) +init session = + let + feedSources = + case Session.cred session of + Just cred -> + FeedSources.fromLists (YourFeed cred) [ GlobalFeed ] + + Nothing -> + FeedSources.fromLists GlobalFeed [] + + loadTags = + Tag.list + |> Http.toTask + in + ( { session = session + , timeZone = Time.utc + , tags = Loading + , feed = Loading + } + , Cmd.batch + [ Feed.init session feedSources + |> Task.attempt CompletedFeedLoad + , Tag.list + |> Http.send CompletedTagsLoad + , Task.perform GotTimeZone Time.here + ] + ) + + + +-- 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" ] + (viewFeed model) + , div [ class "col-md-3" ] + [ div [ class "sidebar" ] <| + case model.tags of + Loaded tags -> + [ p [] [ text "Popular Tags" ] + , viewTags tags + ] + + Loading -> + [ 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." ] + ] + ] + + +{-| 👉 TODO refactor this to accept narrower types than the entire Model. + +💡 HINT: It may end up with multiple arguments! + +-} +viewFeed : Model -> List (Html Msg) +viewFeed model = + case model.feed of + Loaded feed -> + div [ class "feed-toggle" ] + [ Feed.viewFeedSources feed |> Html.map GotFeedMsg ] + :: (Feed.viewArticles model.timeZone feed |> List.map (Html.map GotFeedMsg)) + + Loading -> + [ Loading.icon ] + + Failed -> + [ Loading.error "feed" ] + + +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 + | CompletedFeedLoad (Result Http.Error Feed.Model) + | CompletedTagsLoad (Result Http.Error (List Tag)) + | GotTimeZone Time.Zone + | GotFeedMsg Feed.Msg + | GotSession Session + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + ClickedTag tagName -> + let + subCmd = + Feed.selectTag (Session.cred model.session) tagName + in + ( model, Cmd.map GotFeedMsg subCmd ) + + 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 ) + + Failed -> + ( model, Log.error ) + + GotTimeZone tz -> + ( { model | timeZone = tz }, Cmd.none ) + + GotSession session -> + ( { model | session = session }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session diff --git a/advanced/part3/src/Page/Login.elm b/advanced/part3/src/Page/Login.elm new file mode 100644 index 0000000..134dfe0 --- /dev/null +++ b/advanced/part3/src/Page/Login.elm @@ -0,0 +1,259 @@ +module Page.Login exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| The login page. +-} + +import Api exposing (optionalError) +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 Validate exposing (Valid, Validator, fromValid, ifBlank, validate) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , errors : List Error + , form : Form + } + + +type alias Form = + { email : String + , password : String + } + + +init : Session -> ( Model, Cmd msg ) +init session = + ( { session = session + , errors = [] + , 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 (\( _, error ) -> li [] [ text error ]) model.errors + , 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 "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 formValidator model.form of + Ok validForm -> + ( { model | errors = [] } + , Http.send CompletedLogin (login validForm) + ) + + Err errors -> + ( { model | errors = errors } + , Cmd.none + ) + + EnteredEmail email -> + updateForm (\form -> { form | email = email }) model + + EnteredPassword password -> + updateForm (\form -> { form | password = password }) model + + CompletedLogin (Err error) -> + let + serverErrors = + error + |> Api.listErrors errorsDecoder + |> List.map (\errorMessage -> ( Server, errorMessage )) + |> List.append model.errors + in + ( { model | errors = List.append model.errors serverErrors } + , Cmd.none + ) + + CompletedLogin (Ok cred) -> + ( model + , Session.login cred + ) + + GotSession session -> + ( { model | session = session }, Cmd.none ) + + +{-| 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) + + + +-- VALIDATION + + +type ErrorSource + = Server + | Email + | Password + + +{-| Recording validation errors 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: + +viewFormErrors : Field -> List Error -> Html msg + +...and it filters the list of errors to render only the ones for the given +Field. This way you can call this: + +viewFormErrors Email model.errors + +...next to the `email` field, and call `viewFormErrors Password model.errors` +next to the `password` field, and so on. + +-} +type alias Error = + ( ErrorSource, String ) + + +formValidator : Validator Error Form +formValidator = + Validate.all + [ ifBlank .email ( Email, "email can't be blank." ) + , ifBlank .password ( Password, "password can't be blank." ) + ] + + +errorsDecoder : Decoder (List String) +errorsDecoder = + Decode.succeed (\emailOrPassword email username password -> List.concat [ emailOrPassword, email, username, password ]) + |> optionalError "email or password" + |> optionalError "email" + |> optionalError "username" + |> optionalError "password" + + + +-- HTTP + + +login : Valid Form -> Http.Request Viewer +login validForm = + let + form = + fromValid validForm + + 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 diff --git a/advanced/part3/src/Page/NotFound.elm b/advanced/part3/src/Page/NotFound.elm new file mode 100644 index 0000000..35b03c5 --- /dev/null +++ b/advanced/part3/src/Page/NotFound.elm @@ -0,0 +1,21 @@ +module Page.NotFound exposing (view) + +import Assets +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 [ Assets.src Assets.error ] [] ] + ] + } diff --git a/advanced/part3/src/Page/Profile.elm b/advanced/part3/src/Page/Profile.elm new file mode 100644 index 0000000..996095e --- /dev/null +++ b/advanced/part3/src/Page/Profile.elm @@ -0,0 +1,309 @@ +module Page.Profile exposing (Model, Msg, init, subscriptions, toSession, update, view) + +{-| An Author's profile. +-} + +import Article.Feed as Feed exposing (ListConfig) +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 Loading +import Log +import Page +import Profile exposing (Profile) +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 + + -- Loaded independently from server + , author : Status Author + , feed : Status Feed.Model + } + + +type Status a + = Loading 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 = [] + , author = Loading username + , feed = Loading username + } + , Cmd.batch + [ Author.fetch username maybeCred + |> Http.toTask + |> Task.mapError (Tuple.pair username) + |> Task.attempt CompletedAuthorLoad + , defaultFeedSources username + |> Feed.init session + |> Task.mapError (Tuple.pair username) + |> Task.attempt CompletedFeedLoad + , Task.perform GotTimeZone Time.here + ] + ) + + + +-- 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 -> + if Just username == Maybe.map .username (Session.cred model.session) then + myProfileTitle + + else + defaultTitle + + Failed username -> + -- We can't follow if it hasn't finished loading yet + if Just username == Maybe.map .username (Session.cred model.session) then + myProfileTitle + + else + defaultTitle + 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" ] [ viewFeed model.timeZone feed ] ] + + Loading _ -> + Loading.icon + + Failed _ -> + Loading.error "feed" + ] + + Loading _ -> + Loading.icon + + Failed _ -> + Loading.error "profile" + } + + + +-- PAGE TITLE + + +titleForOther : Username -> String +titleForOther otherUsername = + "Profile — " ++ Username.toString otherUsername + + +myProfileTitle : String +myProfileTitle = + "My Profile" + + +defaultTitle : String +defaultTitle = + "Profile" + + + +-- FEED + + +viewFeed : Time.Zone -> Feed.Model -> Html Msg +viewFeed timeZone feed = + div [ class "col-xs-12 col-md-10 offset-md-1" ] <| + div [ class "articles-toggle" ] + [ Feed.viewFeedSources feed |> Html.map GotFeedMsg ] + :: (Feed.viewArticles timeZone feed |> List.map (Html.map GotFeedMsg)) + + + +-- UPDATE + + +type Msg + = ClickedDismissErrors + | ClickedFollow Cred UnfollowedAuthor + | ClickedUnfollow Cred FollowedAuthor + | 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 + + +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 + ) + + 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 ) + + Failed _ -> + ( model, Log.error ) + + GotTimeZone tz -> + ( { model | timeZone = tz }, Cmd.none ) + + GotSession session -> + ( { model | session = session }, Cmd.none ) + + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions model = + Session.changes GotSession (Session.navKey model.session) + + + +-- EXPORT + + +toSession : Model -> Session +toSession model = + model.session + + + +-- INTERNAL + + +defaultFeedSources : Username -> FeedSources +defaultFeedSources username = + FeedSources.fromLists (AuthorFeed username) [ FavoritedFeed username ] diff --git a/advanced/part3/src/Page/Register.elm b/advanced/part3/src/Page/Register.elm new file mode 100644 index 0000000..05bd8c2 --- /dev/null +++ b/advanced/part3/src/Page/Register.elm @@ -0,0 +1,266 @@ +module Page.Register exposing (Model, Msg, init, subscriptions, toSession, update, view) + +import Api exposing (optionalError) +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 Validate exposing (Valid, Validator, fromValid, ifBlank, validate) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , errors : List Error + , form : Form + } + + +type alias Form = + { email : String + , username : String + , password : String + } + + +init : Session -> ( Model, Cmd msg ) +init session = + ( { session = session + , errors = [] + , 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?" ] + ] + , model.errors + |> List.map (\( _, error ) -> li [] [ text error ]) + |> ul [ class "error-messages" ] + , 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" ] + ] + + + +-- 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 formValidator model.form of + Ok validForm -> + ( { model | errors = [] } + , Http.send CompletedRegister (register validForm) + ) + + Err errors -> + ( { model | errors = errors } + , 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 = + error + |> Api.listErrors errorsDecoder + |> List.map (\errorMessage -> ( Server, errorMessage )) + in + ( { model | errors = List.append model.errors serverErrors } + , Cmd.none + ) + + CompletedRegister (Ok cred) -> + ( model + , Session.login cred + ) + + GotSession session -> + ( { model | session = session }, Cmd.none ) + + +{-| 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 + + + +-- VALIDATION + + +type ErrorSource + = Server + | Username + | Email + | Password + + +type alias Error = + ( ErrorSource, String ) + + +formValidator : Validator Error Form +formValidator = + Validate.all + [ ifBlank .username ( Username, "username can't be blank." ) + , ifBlank .email ( Email, "email can't be blank." ) + , Validate.fromErrors passwordLength + ] + + +minPasswordChars : Int +minPasswordChars = + 6 + + +passwordLength : Form -> List Error +passwordLength { password } = + if String.length password < minPasswordChars then + [ ( Password, "password must be at least " ++ String.fromInt minPasswordChars ++ " characters long." ) ] + + else + [] + + +errorsDecoder : Decoder (List String) +errorsDecoder = + Decode.succeed (\email username password -> List.concat [ email, username, password ]) + |> optionalError "email" + |> optionalError "username" + |> optionalError "password" + + + +-- HTTP + + +register : Valid Form -> Http.Request Viewer +register validForm = + let + form = + fromValid validForm + + 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 diff --git a/advanced/part3/src/Page/Settings.elm b/advanced/part3/src/Page/Settings.elm new file mode 100644 index 0000000..22d4725 --- /dev/null +++ b/advanced/part3/src/Page/Settings.elm @@ -0,0 +1,374 @@ +module Page.Settings exposing (Model, Msg, init, subscriptions, toSession, update, view) + +import Api exposing (optionalError) +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 Validate exposing (Valid, Validator, fromValid, ifBlank, validate) +import Viewer exposing (Viewer) +import Viewer.Cred as Cred exposing (Cred) + + + +-- MODEL + + +type alias Model = + { session : Session + , errors : List Error + , form : Form + } + + +type alias Form = + { avatar : Maybe String + , bio : String + , email : String + , username : String + , password : Maybe String + } + + +init : Session -> ( Model, Cmd msg ) +init session = + ( { session = session + , errors = [] + , form = + case Session.viewer session of + Just viewer -> + let + profile = + Viewer.profile viewer + + cred = + Viewer.cred viewer + in + { avatar = Avatar.toMaybeString (Profile.avatar profile) + , email = Email.toString (Viewer.email viewer) + , bio = Maybe.withDefault "" (Profile.bio profile) + , username = Username.toString cred.username + , password = Nothing + } + + 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 = Nothing + , email = "" + , bio = "" + , username = "" + , password = Nothing + } + } + , 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 = + let + form = + viewForm model + in + { 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" ] + , model.errors + |> List.map (\( _, error ) -> li [] [ text error ]) + |> ul [ class "error-messages" ] + , form + ] + ] + ] + ] + + Nothing -> + text "Sign in to view your settings." + } + + +{-| 👉 TODO refactor this to accept narrower types than the entire Model. + +💡 HINT: It may end up with multiple arguments! + +-} +viewForm : Model -> Html Msg +viewForm model = + let + form = + model.form + in + case Session.cred model.session of + Nothing -> + text "" + + Just cred -> + Html.form [ onSubmit (SubmittedForm cred) ] + [ fieldset [] + [ fieldset [ class "form-group" ] + [ input + [ class "form-control" + , placeholder "URL of profile picture" + , value (Maybe.withDefault "" form.avatar) + , onInput EnteredImage + ] + [] + ] + , 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 (Maybe.withDefault "" form.password) + , onInput EnteredPassword + ] + [] + ] + , button + [ class "btn btn-lg btn-primary pull-xs-right" ] + [ text "Update Settings" ] + ] + ] + + + +-- UPDATE + + +type Msg + = SubmittedForm Cred + | EnteredEmail String + | EnteredUsername String + | EnteredPassword String + | EnteredBio String + | EnteredImage String + | CompletedSave (Result Http.Error Viewer) + | GotSession Session + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + SubmittedForm cred -> + case validate formValidator model.form of + Ok validForm -> + ( { model | errors = [] } + , edit cred validForm + |> Http.send CompletedSave + ) + + Err errors -> + ( { model | errors = errors } + , Cmd.none + ) + + EnteredEmail email -> + updateForm (\form -> { form | email = email }) model + + EnteredUsername username -> + updateForm (\form -> { form | username = username }) model + + EnteredPassword passwordStr -> + let + password = + if String.isEmpty passwordStr then + Nothing + + else + Just passwordStr + in + updateForm (\form -> { form | password = password }) model + + EnteredBio bio -> + updateForm (\form -> { form | bio = bio }) model + + EnteredImage avatarStr -> + let + avatar = + if String.isEmpty avatarStr then + Nothing + + else + Just avatarStr + in + updateForm (\form -> { form | avatar = avatar }) model + + CompletedSave (Err error) -> + let + serverErrors = + error + |> Api.listErrors errorsDecoder + |> List.map (\errorMessage -> ( Server, errorMessage )) + in + ( { model | errors = List.append model.errors serverErrors } + , Cmd.none + ) + + CompletedSave (Ok cred) -> + ( model + , Session.login cred + ) + + GotSession session -> + ( { model | session = session }, Cmd.none ) + + +{-| 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 + + + +-- VALIDATION + + +type ErrorSource + = Server + | Username + | Email + | Password + | ImageUrl + | Bio + + +type alias Error = + ( ErrorSource, String ) + + +formValidator : Validator Error Form +formValidator = + Validate.all + [ ifBlank .username ( Username, "username can't be blank." ) + , ifBlank .email ( Email, "email can't be blank." ) + ] + + +errorsDecoder : Decoder (List String) +errorsDecoder = + Decode.succeed (\email username password -> List.concat [ email, username, password ]) + |> optionalError "email" + |> optionalError "username" + |> optionalError "password" + + + +-- HTTP + + +{-| This takes a Valid Form as a reminder that it needs to have been validated +first. +-} +edit : Cred -> Valid Form -> Http.Request Viewer +edit cred validForm = + let + form = + fromValid validForm + + updates = + [ Just ( "username", Encode.string form.username ) + , Just ( "email", Encode.string form.email ) + , Just ( "bio", Encode.string form.bio ) + , Just ( "image", Maybe.withDefault Encode.null (Maybe.map Encode.string form.avatar) ) + , Maybe.map (\pass -> ( "password", Encode.string pass )) form.password + ] + |> List.filterMap identity + + body = + ( "user", Encode.object updates ) + |> List.singleton + |> Encode.object + |> 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 diff --git a/advanced/part3/src/PaginatedList.elm b/advanced/part3/src/PaginatedList.elm new file mode 100644 index 0000000..9e73e71 --- /dev/null +++ b/advanced/part3/src/PaginatedList.elm @@ -0,0 +1,98 @@ +module PaginatedList exposing (PaginatedList, fromList, map, mapPage, page, total, values, view) + +import Html exposing (Html, a, li, text, ul) +import Html.Attributes exposing (class, classList, href) +import Html.Events exposing (onClick) + + + +-- 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 } + + +mapPage : (Int -> Int) -> PaginatedList a -> PaginatedList a +mapPage transform (PaginatedList info) = + PaginatedList { info | page = transform info.page } + + + +-- VIEW + + +view : (Int -> msg) -> PaginatedList a -> Int -> Html msg +view toMsg list resultsPerPage = + let + totalPages = + ceiling (toFloat (total list) / toFloat resultsPerPage) + + activePage = + page list + + viewPageLink currentPage = + pageLink toMsg currentPage (currentPage == activePage) + in + if totalPages > 1 then + List.range 1 totalPages + |> 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) ] + ] diff --git a/advanced/part3/src/Profile.elm b/advanced/part3/src/Profile.elm new file mode 100644 index 0000000..e8e32e7 --- /dev/null +++ b/advanced/part3/src/Profile.elm @@ -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 diff --git a/advanced/part3/src/Route.elm b/advanced/part3/src/Route.elm new file mode 100644 index 0000000..1e524fe --- /dev/null +++ b/advanced/part3/src/Route.elm @@ -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 diff --git a/advanced/part3/src/Session.elm b/advanced/part3/src/Session.elm new file mode 100644 index 0000000..e1e9ee0 --- /dev/null +++ b/advanced/part3/src/Session.elm @@ -0,0 +1,98 @@ +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 + = Session Internals + + +type alias Internals = + { navKey : Nav.Key + , viewer : Maybe Viewer + } + + + +-- INFO + + +viewer : Session -> Maybe Viewer +viewer (Session info) = + info.viewer + + +cred : Session -> Maybe Cred +cred (Session info) = + Maybe.map Viewer.cred info.viewer + + +navKey : Session -> Nav.Key +navKey (Session info) = + info.navKey + + + +-- 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 (decode key) + |> Sub.map toMsg + + +port onSessionChange : (Value -> msg) -> Sub msg + + +decode : Nav.Key -> Value -> Session +decode key value = + Session + { viewer = Result.toMaybe (Decode.decodeValue Viewer.decoder value) + , navKey = key + } diff --git a/advanced/part3/src/Timestamp.elm b/advanced/part3/src/Timestamp.elm new file mode 100644 index 0000000..fde03e0 --- /dev/null +++ b/advanced/part3/src/Timestamp.elm @@ -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: + + +-} +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 diff --git a/advanced/part3/src/Username.elm b/advanced/part3/src/Username.elm new file mode 100644 index 0000000..a7f17ec --- /dev/null +++ b/advanced/part3/src/Username.elm @@ -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 diff --git a/advanced/part3/src/Viewer.elm b/advanced/part3/src/Viewer.elm new file mode 100644 index 0000000..a070af8 --- /dev/null +++ b/advanced/part3/src/Viewer.elm @@ -0,0 +1,71 @@ +module Viewer exposing (Viewer, cred, decoder, email, encode, 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 + + + +-- SERIALIZATION + + +encode : Viewer -> Value +encode (Viewer info) = + Encode.object + [ ( "email", Email.encode info.email ) + , ( "username", Username.encode info.cred.username ) + , ( "bio", Maybe.withDefault Encode.null (Maybe.map Encode.string (Profile.bio info.profile)) ) + , ( "image", Avatar.encode (Profile.avatar info.profile) ) + , ( "token", Cred.encodeToken info.cred ) + ] + + +decoder : Decoder Viewer +decoder = + Decode.succeed Internals + |> custom Cred.decoder + |> custom Profile.decoder + |> required "email" Email.decoder + |> Decode.map Viewer diff --git a/advanced/part3/src/Viewer/Cred.elm b/advanced/part3/src/Viewer/Cred.elm new file mode 100644 index 0000000..8f0c7c1 --- /dev/null +++ b/advanced/part3/src/Viewer/Cred.elm @@ -0,0 +1,53 @@ +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 alias Cred = + { username : Username + , token : String + } + + + +-- SERIALIZATION + + +decoder : Decoder Cred +decoder = + Decode.succeed Cred + |> required "username" Username.decoder + |> required "token" Decode.string + + + +-- TRANSFORM + + +encodeToken : Cred -> Value +encodeToken cred = + Encode.string cred.token + + +addHeader : Cred -> RequestBuilder a -> RequestBuilder a +addHeader cred builder = + builder + |> withHeader "authorization" ("Token " ++ cred.token) + + +addHeaderIfAvailable : Maybe Cred -> RequestBuilder a -> RequestBuilder a +addHeaderIfAvailable maybeCred builder = + case maybeCred of + Just cred -> + addHeader cred builder + + Nothing -> + builder