├── .gitignore
├── config
└── production
│ └── _redirects
├── sass
├── _sidebar.scss
├── index.scss
├── _article-page.scss
└── _github.scss
├── images
├── favicon.ico
└── smiley-cyrus.jpeg
├── misc
└── elm-realworld-example-app.png
├── src
├── Lib
│ ├── Either.elm
│ ├── Function.elm
│ ├── Task.elm
│ ├── Json
│ │ ├── Encode.elm
│ │ └── Decode.elm
│ ├── Browser
│ │ └── Dom.elm
│ ├── String.elm
│ ├── Basics.elm
│ ├── Html
│ │ └── Attributes.elm
│ ├── RemoteData.elm
│ ├── Port
│ │ └── Message.elm
│ ├── OrderedSet.elm
│ ├── NonEmptyString.elm
│ └── Validation.elm
├── Data
│ ├── Limit.elm
│ ├── Offset.elm
│ ├── Viewer.elm
│ ├── PageNumber.elm
│ ├── Slug.elm
│ ├── Total.elm
│ ├── Token.elm
│ ├── Password.elm
│ ├── Email.elm
│ ├── Tag.elm
│ ├── User.elm
│ ├── Username.elm
│ ├── Comments.elm
│ ├── Config.elm
│ ├── Comment.elm
│ ├── Pager.elm
│ ├── Timestamp.elm
│ ├── Article.elm
│ ├── Route.elm
│ └── Validation.elm
├── View
│ ├── Editor.elm
│ ├── HomeHeader.elm
│ ├── AuthErrors.elm
│ ├── ArticleHeader.elm
│ ├── ArticleActionsForGuest.elm
│ ├── Footer.elm
│ ├── ArticleTabs.elm
│ ├── Layout.elm
│ ├── Login.elm
│ ├── Register.elm
│ ├── Settings.elm
│ ├── Textarea.elm
│ ├── Input.elm
│ ├── ArticleContent.elm
│ ├── Column.elm
│ ├── FavouriteButton.elm
│ ├── LoginForm.elm
│ ├── Tabs.elm
│ ├── Sidebar.elm
│ ├── FeedTabs.elm
│ ├── RegisterForm.elm
│ ├── CommentForm.elm
│ ├── FollowButton.elm
│ ├── Pagination.elm
│ ├── ProfileHeader.elm
│ ├── EditorForm.elm
│ ├── SettingsForm.elm
│ ├── Comment.elm
│ ├── TagInput.elm
│ ├── ArticlePreview.elm
│ ├── ArticleMeta.elm
│ └── Navigation.elm
├── Api
│ ├── DeleteArticle.elm
│ ├── GetTags.elm
│ ├── DeleteComment.elm
│ ├── GetUser.elm
│ ├── GetArticle.elm
│ ├── GetComments.elm
│ ├── CreateArticle.elm
│ ├── UpdateArticle.elm
│ ├── GetProfile.elm
│ ├── ToggleFollow.elm
│ ├── CreateComment.elm
│ ├── Login.elm
│ ├── Register.elm
│ ├── ToggleFavourite.elm
│ ├── UpdateUser.elm
│ └── GetArticles.elm
├── Port
│ └── Outgoing.elm
└── Page
│ ├── Error.elm
│ ├── NotAuthorized.elm
│ ├── NotFound.elm
│ ├── Login.elm
│ ├── Register.elm
│ └── Settings.elm
├── Caddyfile
├── bin
├── clean
├── build-production
├── serve
├── review
├── serve-sandbox
├── serve-prototype
├── format
├── check-scripts
├── build-prototype
├── build-sandbox
├── check
├── deploy-production
└── build
├── review
├── suppressed
│ ├── NoConfusingPrefixOperator.json
│ └── NoMissingTypeExpose.json
├── elm.json
└── src
│ └── ReviewConfig.elm
├── tests
└── Test
│ ├── Lib
│ └── Fuzz.elm
│ └── Data
│ ├── Password.elm
│ ├── Timestamp.elm
│ ├── Tag.elm
│ ├── Route.elm
│ ├── Pager.elm
│ └── Comments.elm
├── devbox.json
├── prototype
├── head.html
├── footer.html
├── login.html
├── register.html
├── editor.html
├── settings.html
├── header.html
├── profile.html
├── home.html
└── article.html
├── sandbox
└── index.html
├── elm.json
├── LICENSE
├── README.md
└── html
└── index.html
/.gitignore:
--------------------------------------------------------------------------------
1 | .build/
2 | .devbox/
3 | elm-stuff/
4 |
--------------------------------------------------------------------------------
/config/production/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/sass/_sidebar.scss:
--------------------------------------------------------------------------------
1 | .sidebar .tag-pill {
2 | border: 0;
3 | }
4 |
--------------------------------------------------------------------------------
/sass/index.scss:
--------------------------------------------------------------------------------
1 | @use "article-page";
2 | @use "github";
3 | @use "sidebar";
4 |
--------------------------------------------------------------------------------
/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwayne/elm-conduit/HEAD/images/favicon.ico
--------------------------------------------------------------------------------
/images/smiley-cyrus.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwayne/elm-conduit/HEAD/images/smiley-cyrus.jpeg
--------------------------------------------------------------------------------
/sass/_article-page.scss:
--------------------------------------------------------------------------------
1 | .article-page .card .mod-options i.is-disabled {
2 | cursor: not-allowed;
3 | }
4 |
--------------------------------------------------------------------------------
/misc/elm-realworld-example-app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dwayne/elm-conduit/HEAD/misc/elm-realworld-example-app.png
--------------------------------------------------------------------------------
/src/Lib/Either.elm:
--------------------------------------------------------------------------------
1 | module Lib.Either exposing (Either(..))
2 |
3 |
4 | type Either l r
5 | = Left l
6 | | Right r
7 |
--------------------------------------------------------------------------------
/src/Lib/Function.elm:
--------------------------------------------------------------------------------
1 | module Lib.Function exposing (flip)
2 |
3 |
4 | flip : (a -> b -> c) -> b -> a -> c
5 | flip f b a =
6 | f a b
7 |
--------------------------------------------------------------------------------
/Caddyfile:
--------------------------------------------------------------------------------
1 | :{$port}
2 |
3 | root * {$build}/application
4 |
5 | try_files {path} /
6 |
7 | file_server {
8 | browse
9 | precompressed br gzip
10 | }
11 |
--------------------------------------------------------------------------------
/bin/clean:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: clean
5 | #
6 |
7 | set -euo pipefail
8 |
9 | cd "${project:?}" && rm -rf .build .devbox elm-stuff
10 |
--------------------------------------------------------------------------------
/src/Lib/Task.elm:
--------------------------------------------------------------------------------
1 | module Lib.Task exposing (dispatch)
2 |
3 | import Task
4 |
5 |
6 | dispatch : msg -> Cmd msg
7 | dispatch =
8 | Task.succeed >> Task.perform identity
9 |
--------------------------------------------------------------------------------
/bin/build-production:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: build-production
5 | #
6 |
7 | set -euo pipefail
8 |
9 | optimize_html=1 optimize_css=1 optimize_js=2 compress=1 build
10 |
--------------------------------------------------------------------------------
/src/Lib/Json/Encode.elm:
--------------------------------------------------------------------------------
1 | module Lib.Json.Encode exposing (url)
2 |
3 | import Json.Encode as JE
4 | import Url exposing (Url)
5 |
6 |
7 | url : Url -> JE.Value
8 | url =
9 | JE.string << Url.toString
10 |
--------------------------------------------------------------------------------
/bin/serve:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: serve [8000]
5 | #
6 |
7 | set -euo pipefail
8 |
9 | port="${1:-8000}"
10 |
11 | xdg-open "http://localhost:$port" && \
12 | port="$port" caddy run --config "${project:?}/Caddyfile"
13 |
--------------------------------------------------------------------------------
/src/Lib/Browser/Dom.elm:
--------------------------------------------------------------------------------
1 | module Lib.Browser.Dom exposing (focus)
2 |
3 | import Browser.Dom as BD
4 | import Task
5 |
6 |
7 | focus : String -> msg -> Cmd msg
8 | focus id msg =
9 | BD.focus id
10 | |> Task.attempt (always msg)
11 |
--------------------------------------------------------------------------------
/src/Lib/String.elm:
--------------------------------------------------------------------------------
1 | module Lib.String exposing (pluralize)
2 |
3 |
4 | pluralize : Int -> { singular : String, plural : String } -> String
5 | pluralize n { singular, plural } =
6 | if n == 1 then
7 | singular
8 |
9 | else
10 | plural
11 |
--------------------------------------------------------------------------------
/bin/review:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: review [args-for-elm-review]
5 | #
6 |
7 | set -euo pipefail
8 |
9 | cd "${project:?}" && \
10 | elm-review \
11 | review/src \
12 | src \
13 | tests \
14 | "$@"
15 |
--------------------------------------------------------------------------------
/bin/serve-sandbox:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: serve-sandbox [9001]
5 | #
6 |
7 | set -euo pipefail
8 |
9 | port="${1:-9001}"
10 |
11 | xdg-open "http://localhost:$port" && \
12 | caddy file-server --browse --listen :"$port" --root "${build:?}/sandbox"
13 |
--------------------------------------------------------------------------------
/bin/serve-prototype:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: serve-prototype [9000]
5 | #
6 |
7 | set -euo pipefail
8 |
9 | port="${1:-9000}"
10 |
11 | xdg-open "http://localhost:$port" && \
12 | caddy file-server --browse --listen :"$port" --root "${build:?}/prototype"
13 |
--------------------------------------------------------------------------------
/src/Lib/Basics.elm:
--------------------------------------------------------------------------------
1 | module Lib.Basics exposing (reverseOrder)
2 |
3 |
4 | reverseOrder : Order -> Order
5 | reverseOrder order =
6 | case order of
7 | LT ->
8 | GT
9 |
10 | EQ ->
11 | EQ
12 |
13 | GT ->
14 | LT
15 |
--------------------------------------------------------------------------------
/bin/format:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: format [args-for-elm-format]
5 | #
6 |
7 | set -euo pipefail
8 |
9 | cd "${project:?}" && \
10 | elm-format \
11 | review/src \
12 | src \
13 | tests \
14 | "${@:---yes}"
15 |
--------------------------------------------------------------------------------
/src/Data/Limit.elm:
--------------------------------------------------------------------------------
1 | module Data.Limit exposing (Limit, five, ten, toInt)
2 |
3 |
4 | type Limit
5 | = Limit Int
6 |
7 |
8 | five : Limit
9 | five =
10 | Limit 5
11 |
12 |
13 | ten : Limit
14 | ten =
15 | Limit 10
16 |
17 |
18 | toInt : Limit -> Int
19 | toInt (Limit n) =
20 | n
21 |
--------------------------------------------------------------------------------
/review/suppressed/NoConfusingPrefixOperator.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "automatically created by": "elm-review suppress",
4 | "learn more": "elm-review suppress --help",
5 | "suppressions": [
6 | { "count": 1, "filePath": "src/Data/Token.elm" },
7 | { "count": 1, "filePath": "src/Lib/Html/Attributes.elm" }
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/review/suppressed/NoMissingTypeExpose.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "automatically created by": "elm-review suppress",
4 | "learn more": "elm-review suppress --help",
5 | "suppressions": [
6 | { "count": 3, "filePath": "src/Main.elm" },
7 | { "count": 1, "filePath": "src/Page/Editor.elm" },
8 | { "count": 1, "filePath": "src/Page/Home.elm" }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/src/Data/Offset.elm:
--------------------------------------------------------------------------------
1 | module Data.Offset exposing (Offset, fromInt, toInt, zero)
2 |
3 |
4 | type Offset
5 | = Offset Int
6 |
7 |
8 | zero : Offset
9 | zero =
10 | Offset 0
11 |
12 |
13 | fromInt : Int -> Offset
14 | fromInt n =
15 | if n > 0 then
16 | Offset n
17 |
18 | else
19 | zero
20 |
21 |
22 | toInt : Offset -> Int
23 | toInt (Offset n) =
24 | n
25 |
--------------------------------------------------------------------------------
/src/Data/Viewer.elm:
--------------------------------------------------------------------------------
1 | module Data.Viewer exposing (Viewer(..), toToken)
2 |
3 | import Data.Token exposing (Token)
4 | import Data.User exposing (User)
5 |
6 |
7 | type Viewer
8 | = Guest
9 | | User User
10 |
11 |
12 | toToken : Viewer -> Maybe Token
13 | toToken viewer =
14 | case viewer of
15 | Guest ->
16 | Nothing
17 |
18 | User { token } ->
19 | Just token
20 |
--------------------------------------------------------------------------------
/src/Lib/Html/Attributes.elm:
--------------------------------------------------------------------------------
1 | module Lib.Html.Attributes exposing (attrList)
2 |
3 | import Html as H
4 |
5 |
6 | attrList : List (H.Attribute msg) -> List ( H.Attribute msg, Bool ) -> List (H.Attribute msg)
7 | attrList base =
8 | List.filterMap
9 | (\( attr, isTrue ) ->
10 | if isTrue then
11 | Just attr
12 |
13 | else
14 | Nothing
15 | )
16 | >> (++) base
17 |
--------------------------------------------------------------------------------
/src/Lib/RemoteData.elm:
--------------------------------------------------------------------------------
1 | module Lib.RemoteData exposing (RemoteData(..), map)
2 |
3 |
4 | type RemoteData e a
5 | = Loading
6 | | Success a
7 | | Failure e
8 |
9 |
10 | map : (a -> b) -> RemoteData e a -> RemoteData e b
11 | map f remoteData =
12 | case remoteData of
13 | Loading ->
14 | Loading
15 |
16 | Success a ->
17 | Success (f a)
18 |
19 | Failure e ->
20 | Failure e
21 |
--------------------------------------------------------------------------------
/bin/check-scripts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: check-scripts
5 | #
6 |
7 | set -euo pipefail
8 |
9 | project="${project:?}"
10 |
11 | shellcheck --norc -xP "$project/bin" "$project/bin/"*
12 | #
13 | # --no-rc Don't look for .shellcheckrc files
14 | # -x Allow 'source' outside of FILES
15 | # -P "$project/bin" Specify path when looking for sourced files ("SCRIPTDIR" for script's dir)
16 | # "$project"/bin/* FILES...
17 | #
18 |
--------------------------------------------------------------------------------
/src/Data/PageNumber.elm:
--------------------------------------------------------------------------------
1 | module Data.PageNumber exposing (PageNumber, fromInt, one, toInt, toString)
2 |
3 |
4 | type PageNumber
5 | = PageNumber Int
6 |
7 |
8 | one : PageNumber
9 | one =
10 | PageNumber 1
11 |
12 |
13 | fromInt : Int -> PageNumber
14 | fromInt =
15 | max 1 >> PageNumber
16 |
17 |
18 | toInt : PageNumber -> Int
19 | toInt (PageNumber n) =
20 | n
21 |
22 |
23 | toString : PageNumber -> String
24 | toString =
25 | toInt >> String.fromInt
26 |
--------------------------------------------------------------------------------
/src/View/Editor.elm:
--------------------------------------------------------------------------------
1 | module View.Editor exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import View.AuthErrors as AuthErrors
5 | import View.EditorForm as EditorForm
6 |
7 |
8 | type alias ViewOptions msg =
9 | { form : EditorForm.ViewOptions msg
10 | , errorMessages : List String
11 | }
12 |
13 |
14 | view : ViewOptions msg -> H.Html msg
15 | view { form, errorMessages } =
16 | H.div []
17 | [ AuthErrors.view errorMessages
18 | , EditorForm.view form
19 | ]
20 |
--------------------------------------------------------------------------------
/src/View/HomeHeader.elm:
--------------------------------------------------------------------------------
1 | module View.HomeHeader exposing (view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 |
6 |
7 | view : H.Html msg
8 | view =
9 | H.div
10 | [ HA.class "banner" ]
11 | [ H.div
12 | [ HA.class "container" ]
13 | [ H.h1
14 | [ HA.class "logo-font" ]
15 | [ H.text "conduit" ]
16 | , H.p []
17 | [ H.text "A place to share your knowledge." ]
18 | ]
19 | ]
20 |
--------------------------------------------------------------------------------
/src/View/AuthErrors.elm:
--------------------------------------------------------------------------------
1 | module View.AuthErrors exposing (view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 |
6 |
7 | view : List String -> H.Html msg
8 | view errorMessages =
9 | if List.isEmpty errorMessages then
10 | H.text ""
11 |
12 | else
13 | errorMessages
14 | |> List.map viewErrorMessage
15 | |> H.ul [ HA.class "error-messages" ]
16 |
17 |
18 | viewErrorMessage : String -> H.Html msg
19 | viewErrorMessage errorMessage =
20 | H.li [] [ H.text errorMessage ]
21 |
--------------------------------------------------------------------------------
/bin/build-prototype:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: build-prototype
5 | #
6 |
7 | set -euo pipefail
8 |
9 | src="${project:?}"
10 | out="${build:?}/prototype"
11 |
12 | clean () {
13 | rm -rf "$out"
14 | }
15 |
16 | prepare () {
17 | mkdir -p "$out"
18 | }
19 |
20 | build_images () {
21 | cp -r "$src/images" "$out"
22 | }
23 |
24 | build_html () {
25 | cp "$src/prototype/"*.html "$out"
26 | }
27 |
28 | build_css () {
29 | sass --embed-sources "$src/sass/index.scss" "$out/index.css"
30 | }
31 |
32 | clean && prepare && build_images && build_html && build_css
33 |
--------------------------------------------------------------------------------
/tests/Test/Lib/Fuzz.elm:
--------------------------------------------------------------------------------
1 | module Test.Lib.Fuzz exposing (onlyAsciiWhitespace)
2 |
3 | import Fuzz exposing (Fuzzer)
4 |
5 |
6 | onlyAsciiWhitespace : Fuzzer String
7 | onlyAsciiWhitespace =
8 | --
9 | -- https://www.ascii-code.com/characters/white-space-characters
10 | --
11 | [ 9 -- Horizontal Tab
12 | , 10 -- Line Feed
13 | , 11 -- Vertical Tabulation
14 | , 12 -- Form Feed
15 | , 13 -- Carriage Return
16 | , 32 -- Space
17 | ]
18 | |> List.map Char.fromCode
19 | |> Fuzz.oneOfValues
20 | |> Fuzz.list
21 | |> Fuzz.map String.fromList
22 |
--------------------------------------------------------------------------------
/src/View/ArticleHeader.elm:
--------------------------------------------------------------------------------
1 | module View.ArticleHeader exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import View.ArticleMeta as ArticleMeta
6 |
7 |
8 | type alias ViewOptions msg =
9 | { title : String
10 | , meta : ArticleMeta.ViewOptions msg
11 | }
12 |
13 |
14 | view : ViewOptions msg -> H.Html msg
15 | view { title, meta } =
16 | H.div
17 | [ HA.class "banner" ]
18 | [ H.div
19 | [ HA.class "container" ]
20 | [ H.h1 [] [ H.text title ]
21 | , ArticleMeta.view meta
22 | ]
23 | ]
24 |
--------------------------------------------------------------------------------
/src/Data/Slug.elm:
--------------------------------------------------------------------------------
1 | module Data.Slug exposing
2 | ( Slug
3 | , decoder
4 | , fromString
5 | , toString
6 | )
7 |
8 | import Json.Decode as JD
9 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
10 |
11 |
12 | type Slug
13 | = Slug NonEmptyString
14 |
15 |
16 | fromString : String -> Maybe Slug
17 | fromString =
18 | Maybe.map Slug << NonEmptyString.fromString
19 |
20 |
21 | decoder : JD.Decoder Slug
22 | decoder =
23 | JD.map Slug NonEmptyString.decoder
24 |
25 |
26 | toString : Slug -> String
27 | toString (Slug slug) =
28 | NonEmptyString.toString slug
29 |
--------------------------------------------------------------------------------
/src/Data/Total.elm:
--------------------------------------------------------------------------------
1 | module Data.Total exposing
2 | ( Total
3 | , decoder
4 | , fromInt
5 | , toInt
6 | , toString
7 | , zero
8 | )
9 |
10 | import Json.Decode as JD
11 |
12 |
13 | type Total
14 | = Total Int
15 |
16 |
17 | zero : Total
18 | zero =
19 | Total 0
20 |
21 |
22 | fromInt : Int -> Total
23 | fromInt =
24 | max 0 >> Total
25 |
26 |
27 | decoder : JD.Decoder Total
28 | decoder =
29 | JD.map fromInt JD.int
30 |
31 |
32 | toInt : Total -> Int
33 | toInt (Total n) =
34 | n
35 |
36 |
37 | toString : Total -> String
38 | toString =
39 | toInt >> String.fromInt
40 |
--------------------------------------------------------------------------------
/src/Lib/Json/Decode.elm:
--------------------------------------------------------------------------------
1 | module Lib.Json.Decode exposing (nullableString, url)
2 |
3 | import Json.Decode as JD
4 | import Url exposing (Url)
5 |
6 |
7 | nullableString : JD.Decoder String
8 | nullableString =
9 | JD.string
10 | |> JD.nullable
11 | |> JD.map (Maybe.withDefault "")
12 |
13 |
14 | url : JD.Decoder Url
15 | url =
16 | JD.string
17 | |> JD.andThen
18 | (\s ->
19 | case Url.fromString s of
20 | Just validUrl ->
21 | JD.succeed validUrl
22 |
23 | Nothing ->
24 | JD.fail <| "Expected a URL: " ++ s
25 | )
26 |
--------------------------------------------------------------------------------
/src/View/ArticleActionsForGuest.elm:
--------------------------------------------------------------------------------
1 | module View.ArticleActionsForGuest exposing (view)
2 |
3 | import Data.Route as Route
4 | import Html as H
5 | import Html.Attributes as HA
6 |
7 |
8 | view : H.Html msg
9 | view =
10 | H.div
11 | [ HA.class "article-actions" ]
12 | [ H.p []
13 | [ H.a
14 | [ HA.href <| Route.toString Route.Login ]
15 | [ H.text "Sign in" ]
16 | , H.text " or "
17 | , H.a
18 | [ HA.href <| Route.toString Route.Register ]
19 | [ H.text "Sign up" ]
20 | , H.text " to add comments on this article."
21 | ]
22 | ]
23 |
--------------------------------------------------------------------------------
/bin/build-sandbox:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: build-sandbox
5 | #
6 |
7 | set -euo pipefail
8 |
9 | src="${project:?}"
10 | out="${build:?}/sandbox"
11 |
12 | clean () {
13 | rm -rf "$out"
14 | }
15 |
16 | prepare () {
17 | mkdir -p "$out"
18 | }
19 |
20 | build_images () {
21 | cp -r "$src/images" "$out"
22 | }
23 |
24 | build_html () {
25 | cp "$src/sandbox/"*.html "$out"
26 | }
27 |
28 | build_css () {
29 | sass --embed-sources "$src/sass/index.scss" "$out/index.css"
30 | }
31 |
32 | build_js () {
33 | elm make "$src/src/Sandbox.elm" --debug --output "$out/app.js"
34 | }
35 |
36 | clean && prepare && build_images && build_html && build_css && build_js
37 |
--------------------------------------------------------------------------------
/src/Data/Token.elm:
--------------------------------------------------------------------------------
1 | module Data.Token exposing
2 | ( Token
3 | , decoder
4 | , toAuthorizationHeader
5 | , toString
6 | )
7 |
8 | import Http
9 | import Json.Decode as JD
10 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
11 |
12 |
13 | type Token
14 | = Token NonEmptyString
15 |
16 |
17 | decoder : JD.Decoder Token
18 | decoder =
19 | JD.map Token NonEmptyString.decoder
20 |
21 |
22 | toAuthorizationHeader : Token -> Http.Header
23 | toAuthorizationHeader =
24 | toString >> (++) "Token " >> Http.header "Authorization"
25 |
26 |
27 | toString : Token -> String
28 | toString (Token token) =
29 | NonEmptyString.toString token
30 |
--------------------------------------------------------------------------------
/src/Api/DeleteArticle.elm:
--------------------------------------------------------------------------------
1 | module Api.DeleteArticle exposing (Options, deleteArticle)
2 |
3 | import Api
4 | import Data.Slug as Slug exposing (Slug)
5 | import Data.Token exposing (Token)
6 | import Url exposing (Url)
7 |
8 |
9 | type alias Options msg =
10 | { token : Token
11 | , slug : Slug
12 | , onResponse : Result (Api.Error ()) () -> msg
13 | }
14 |
15 |
16 | deleteArticle : Url -> Options msg -> Cmd msg
17 | deleteArticle baseUrl { token, slug, onResponse } =
18 | Api.delete
19 | { token = token
20 | , url = Api.buildUrl baseUrl [ "articles", Slug.toString slug ] [] []
21 | , default = ()
22 | , onResponse = onResponse
23 | }
24 |
--------------------------------------------------------------------------------
/devbox.json:
--------------------------------------------------------------------------------
1 | {
2 | "packages": {
3 | "brotli": "latest",
4 | "caddy": {
5 | "version": "latest",
6 | "disable_plugin": true
7 | },
8 | "dart-sass": "latest",
9 | "elmPackages.elm": "latest",
10 | "elmPackages.elm-format": "latest",
11 | "elmPackages.elm-optimize-level-2": "latest",
12 | "elmPackages.elm-review": "latest",
13 | "elmPackages.elm-test": "latest",
14 | "html-minifier": "latest",
15 | "optipng": "latest",
16 | "shellcheck": "latest",
17 | "terser": "latest",
18 | "xdg-utils": "latest",
19 | "zopfli": "latest"
20 | },
21 | "env": {
22 | "project": "$PWD",
23 | "build": "$PWD/.build",
24 | "PATH": "$PWD/bin:$PATH"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/Data/Password.elm:
--------------------------------------------------------------------------------
1 | module Data.Password exposing
2 | ( Error(..)
3 | , Password
4 | , encode
5 | , fromString
6 | )
7 |
8 | import Json.Encode as JE
9 |
10 |
11 | type Password
12 | = Password String
13 |
14 |
15 | type Error
16 | = Blank
17 | | TooShort Int
18 |
19 |
20 | fromString : String -> Result Error Password
21 | fromString s =
22 | let
23 | t =
24 | String.trim s
25 | in
26 | if String.isEmpty t then
27 | Err Blank
28 |
29 | else if String.length t < 6 then
30 | Err <| TooShort 6
31 |
32 | else
33 | Ok <| Password t
34 |
35 |
36 | encode : Password -> JE.Value
37 | encode (Password password) =
38 | JE.string password
39 |
--------------------------------------------------------------------------------
/src/Lib/Port/Message.elm:
--------------------------------------------------------------------------------
1 | module Lib.Port.Message exposing (Message, empty, encode, string)
2 |
3 | import Json.Encode as JE
4 |
5 |
6 | type Message
7 | = Message
8 | { tag : String
9 | , value : JE.Value
10 | }
11 |
12 |
13 | empty : String -> Message
14 | empty tag =
15 | Message
16 | { tag = tag
17 | , value = JE.null
18 | }
19 |
20 |
21 | string : String -> String -> Message
22 | string tag s =
23 | Message
24 | { tag = tag
25 | , value = JE.string s
26 | }
27 |
28 |
29 | encode : Message -> JE.Value
30 | encode (Message { tag, value }) =
31 | JE.object
32 | [ ( "tag", JE.string tag )
33 | , ( "value", value )
34 | ]
35 |
--------------------------------------------------------------------------------
/src/Api/GetTags.elm:
--------------------------------------------------------------------------------
1 | module Api.GetTags exposing (Options, Tags, getTags)
2 |
3 | import Api
4 | import Data.Tag as Tag exposing (Tag)
5 | import Json.Decode as JD
6 | import Url exposing (Url)
7 |
8 |
9 | type alias Options msg =
10 | { onResponse : Result (Api.Error ()) Tags -> msg
11 | }
12 |
13 |
14 | getTags : Url -> Options msg -> Cmd msg
15 | getTags baseUrl { onResponse } =
16 | Api.get
17 | { maybeToken = Nothing
18 | , url = Api.buildUrl baseUrl [ "tags" ] [] []
19 | , onResponse = onResponse
20 | , decoder = decoder
21 | }
22 |
23 |
24 | type alias Tags =
25 | List Tag
26 |
27 |
28 | decoder : JD.Decoder Tags
29 | decoder =
30 | JD.field "tags" (JD.list Tag.decoder)
31 |
--------------------------------------------------------------------------------
/src/Api/DeleteComment.elm:
--------------------------------------------------------------------------------
1 | module Api.DeleteComment exposing (Options, deleteComment)
2 |
3 | import Api
4 | import Data.Slug as Slug exposing (Slug)
5 | import Data.Token exposing (Token)
6 | import Url exposing (Url)
7 |
8 |
9 | type alias Options msg =
10 | { token : Token
11 | , slug : Slug
12 | , id : Int
13 | , onResponse : Result (Api.Error ()) Int -> msg
14 | }
15 |
16 |
17 | deleteComment : Url -> Options msg -> Cmd msg
18 | deleteComment baseUrl { token, slug, id, onResponse } =
19 | Api.delete
20 | { token = token
21 | , url = Api.buildUrl baseUrl [ "articles", Slug.toString slug, "comments", String.fromInt id ] [] []
22 | , default = id
23 | , onResponse = onResponse
24 | }
25 |
--------------------------------------------------------------------------------
/src/Api/GetUser.elm:
--------------------------------------------------------------------------------
1 | module Api.GetUser exposing (Options, getUser)
2 |
3 | import Api
4 | import Data.Token exposing (Token)
5 | import Data.User as User exposing (User)
6 | import Json.Decode as JD
7 | import Url exposing (Url)
8 |
9 |
10 | type alias Options msg =
11 | { token : Token
12 | , onResponse : Result (Api.Error ()) User -> msg
13 | }
14 |
15 |
16 | getUser : Url -> Options msg -> Cmd msg
17 | getUser baseUrl { token, onResponse } =
18 | Api.get
19 | { maybeToken = Just token
20 | , url = Api.buildUrl baseUrl [ "user" ] [] []
21 | , onResponse = onResponse
22 | , decoder = decoder
23 | }
24 |
25 |
26 | decoder : JD.Decoder User
27 | decoder =
28 | JD.field "user" User.decoder
29 |
--------------------------------------------------------------------------------
/src/Port/Outgoing.elm:
--------------------------------------------------------------------------------
1 | port module Port.Outgoing exposing (deleteToken, logError, saveToken)
2 |
3 | import Data.Token as Token exposing (Token)
4 | import Json.Encode as JE
5 | import Lib.Port.Message as Message exposing (Message)
6 |
7 |
8 | deleteToken : Cmd msg
9 | deleteToken =
10 | sendMessage <|
11 | Message.empty "deleteToken"
12 |
13 |
14 | saveToken : Token -> Cmd msg
15 | saveToken token =
16 | sendMessage <|
17 | Message.string "saveToken" (Token.toString token)
18 |
19 |
20 | logError : String -> Cmd msg
21 | logError =
22 | sendMessage << Message.string "logError"
23 |
24 |
25 | sendMessage : Message -> Cmd msg
26 | sendMessage =
27 | Message.encode >> send
28 |
29 |
30 | port send : JE.Value -> Cmd msg
31 |
--------------------------------------------------------------------------------
/bin/check:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: check
5 | #
6 |
7 | set -euo pipefail
8 |
9 | fail () {
10 | echo "$1" >&2
11 | exit 1
12 | }
13 |
14 | # Check scripts
15 |
16 | if ! check-scripts &>/dev/null; then
17 | check-scripts
18 | fi
19 |
20 | # Format
21 |
22 | if ! format --validate &>/dev/null; then
23 | fail "Your code needs to be formatted. Run: format"
24 | fi
25 |
26 | # Build prototype
27 |
28 | if ! build-prototype &>/dev/null; then
29 | build-prototype
30 | fi
31 |
32 | # Build sandbox
33 |
34 | if ! build-sandbox &>/dev/null; then
35 | build-sandbox
36 | fi
37 |
38 | # Build
39 |
40 | if ! build &>/dev/null; then
41 | build
42 | fi
43 |
44 | # Review
45 |
46 | if ! review &>/dev/null; then
47 | review
48 | fi
49 |
--------------------------------------------------------------------------------
/sass/_github.scss:
--------------------------------------------------------------------------------
1 | .github {
2 | display: inline-flex;
3 | position: fixed;
4 | top: 0;
5 | right: 0;
6 | }
7 |
8 | .octocat {
9 | fill: hsl(0deg 0% 13%);
10 | color: hsl(0deg 0% 97%);
11 | }
12 |
13 | .octocat__arm {
14 | transform-origin: 130px 106px;
15 | animation: octocat-wave 560ms ease-in-out;
16 | }
17 |
18 | @media (width > 500px) {
19 | .octocat__arm {
20 | animation: none;
21 | }
22 |
23 | .octocat:hover .octocat__arm {
24 | animation: octocat-wave 560ms ease-in-out;
25 | }
26 | }
27 |
28 | @keyframes octocat-wave {
29 | 0%, 100% {
30 | transform: rotate(0);
31 | }
32 |
33 | 20%, 60% {
34 | transform: rotate(-25deg);
35 | }
36 |
37 | 40%, 80% {
38 | transform: rotate(10deg);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/Data/Email.elm:
--------------------------------------------------------------------------------
1 | module Data.Email exposing
2 | ( Email
3 | , decoder
4 | , encode
5 | , fromString
6 | , toString
7 | )
8 |
9 | import Json.Decode as JD
10 | import Json.Encode as JE
11 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
12 |
13 |
14 | type Email
15 | = Email NonEmptyString
16 |
17 |
18 | fromString : String -> Maybe Email
19 | fromString =
20 | Maybe.map Email << NonEmptyString.fromString
21 |
22 |
23 | decoder : JD.Decoder Email
24 | decoder =
25 | JD.map Email NonEmptyString.decoder
26 |
27 |
28 | encode : Email -> JE.Value
29 | encode (Email email) =
30 | NonEmptyString.encode email
31 |
32 |
33 | toString : Email -> String
34 | toString (Email email) =
35 | NonEmptyString.toString email
36 |
--------------------------------------------------------------------------------
/src/Data/Tag.elm:
--------------------------------------------------------------------------------
1 | module Data.Tag exposing
2 | ( Tag
3 | , decoder
4 | , encode
5 | , fromString
6 | , toString
7 | )
8 |
9 | import Json.Decode as JD
10 | import Json.Encode as JE
11 | import Lib.NonEmptyString as NonEmptyString
12 |
13 |
14 | type Tag
15 | = Tag String
16 |
17 |
18 | fromString : String -> Maybe Tag
19 | fromString =
20 | Maybe.map (Tag << NonEmptyString.toString) << NonEmptyString.fromString
21 |
22 |
23 | decoder : JD.Decoder Tag
24 | decoder =
25 | --
26 | -- Unfortunately the backend allows the tag to be blank.
27 | --
28 | JD.map Tag JD.string
29 |
30 |
31 | encode : Tag -> JE.Value
32 | encode =
33 | JE.string << toString
34 |
35 |
36 | toString : Tag -> String
37 | toString (Tag tag) =
38 | tag
39 |
--------------------------------------------------------------------------------
/src/Data/User.elm:
--------------------------------------------------------------------------------
1 | module Data.User exposing (User, decoder)
2 |
3 | import Data.Email as Email exposing (Email)
4 | import Data.Token as Token exposing (Token)
5 | import Data.Username as Username exposing (Username)
6 | import Json.Decode as JD
7 | import Lib.Json.Decode as JD
8 | import Url exposing (Url)
9 |
10 |
11 | type alias User =
12 | { username : Username
13 | , email : Email
14 | , bio : String
15 | , imageUrl : Url
16 | , token : Token
17 | }
18 |
19 |
20 | decoder : JD.Decoder User
21 | decoder =
22 | JD.map5 User
23 | (JD.field "username" Username.decoder)
24 | (JD.field "email" Email.decoder)
25 | (JD.field "bio" JD.nullableString)
26 | (JD.field "image" JD.url)
27 | (JD.field "token" Token.decoder)
28 |
--------------------------------------------------------------------------------
/src/Data/Username.elm:
--------------------------------------------------------------------------------
1 | module Data.Username exposing
2 | ( Username
3 | , decoder
4 | , encode
5 | , fromString
6 | , toString
7 | )
8 |
9 | import Json.Decode as JD
10 | import Json.Encode as JE
11 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
12 |
13 |
14 | type Username
15 | = Username NonEmptyString
16 |
17 |
18 | fromString : String -> Maybe Username
19 | fromString =
20 | Maybe.map Username << NonEmptyString.fromString
21 |
22 |
23 | decoder : JD.Decoder Username
24 | decoder =
25 | JD.map Username NonEmptyString.decoder
26 |
27 |
28 | encode : Username -> JE.Value
29 | encode (Username username) =
30 | NonEmptyString.encode username
31 |
32 |
33 | toString : Username -> String
34 | toString (Username username) =
35 | NonEmptyString.toString username
36 |
--------------------------------------------------------------------------------
/src/View/Footer.elm:
--------------------------------------------------------------------------------
1 | module View.Footer exposing (view)
2 |
3 | import Data.Route as Route
4 | import Html as H
5 | import Html.Attributes as HA
6 |
7 |
8 | view : H.Html msg
9 | view =
10 | H.footer []
11 | [ H.div
12 | [ HA.class "container" ]
13 | [ H.a
14 | [ HA.class "logo-font"
15 | , HA.href <| Route.toString Route.Home
16 | ]
17 | [ H.text "conduit" ]
18 | , H.span
19 | [ HA.class "attribution" ]
20 | [ H.text "An interactive learning project from "
21 | , H.a
22 | [ HA.href "https://thinkster.io" ]
23 | [ H.text "Thinkster" ]
24 | , H.text ". Code & design licensed under MIT."
25 | ]
26 | ]
27 | ]
28 |
--------------------------------------------------------------------------------
/src/View/ArticleTabs.elm:
--------------------------------------------------------------------------------
1 | module View.ArticleTabs exposing (Tab(..), ViewOptions, view)
2 |
3 | import Html as H
4 | import View.Tabs as Tabs
5 |
6 |
7 | type alias ViewOptions msg =
8 | { activeTab : Tab
9 | , isDisabled : Bool
10 | , onSwitch : Tab -> msg
11 | }
12 |
13 |
14 | type Tab
15 | = Personal
16 | | Favourites
17 |
18 |
19 | view : ViewOptions msg -> H.Html msg
20 | view { activeTab, isDisabled, onSwitch } =
21 | Tabs.view
22 | { name = "articles"
23 | , tabs =
24 | [ { id = Personal
25 | , title = "My Articles"
26 | }
27 | , { id = Favourites
28 | , title = "Favourited Articles"
29 | }
30 | ]
31 | , activeTab = activeTab
32 | , isDisabled = isDisabled
33 | , onSwitch = onSwitch
34 | }
35 |
--------------------------------------------------------------------------------
/src/View/Layout.elm:
--------------------------------------------------------------------------------
1 | module View.Layout exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import View.Footer as Footer
6 | import View.Navigation as Navigation
7 |
8 |
9 | type alias ViewOptions msg =
10 | { name : String
11 | , role : Navigation.Role msg
12 | , maybeHeader : Maybe (H.Html msg)
13 | }
14 |
15 |
16 | view : ViewOptions msg -> List (H.Html msg) -> H.Html msg
17 | view { name, role, maybeHeader } content =
18 | H.div []
19 | [ Navigation.view { role = role }
20 | , H.div [ HA.class <| name ++ "-page" ] <|
21 | List.concat
22 | [ maybeHeader
23 | |> Maybe.map List.singleton
24 | |> Maybe.withDefault []
25 | , [ H.div [ HA.class "container page" ] content ]
26 | ]
27 | , Footer.view
28 | ]
29 |
--------------------------------------------------------------------------------
/src/View/Login.elm:
--------------------------------------------------------------------------------
1 | module View.Login exposing (ViewOptions, view)
2 |
3 | import Data.Route as Route
4 | import Html as H
5 | import Html.Attributes as HA
6 | import View.AuthErrors as AuthErrors
7 | import View.LoginForm as LoginForm
8 |
9 |
10 | type alias ViewOptions msg =
11 | { form : LoginForm.ViewOptions msg
12 | , errorMessages : List String
13 | }
14 |
15 |
16 | view : ViewOptions msg -> H.Html msg
17 | view { form, errorMessages } =
18 | H.div []
19 | [ H.h1
20 | [ HA.class "text-xs-center" ]
21 | [ H.text "Sign in" ]
22 | , H.p
23 | [ HA.class "text-xs-center" ]
24 | [ H.a
25 | [ HA.href <| Route.toString Route.Register ]
26 | [ H.text "Need an account?" ]
27 | ]
28 | , AuthErrors.view errorMessages
29 | , LoginForm.view form
30 | ]
31 |
--------------------------------------------------------------------------------
/src/Api/GetArticle.elm:
--------------------------------------------------------------------------------
1 | module Api.GetArticle exposing (Options, getArticle)
2 |
3 | import Api
4 | import Data.Article as Article exposing (Article)
5 | import Data.Slug as Slug exposing (Slug)
6 | import Data.Token exposing (Token)
7 | import Json.Decode as JD
8 | import Url exposing (Url)
9 |
10 |
11 | type alias Options msg =
12 | { maybeToken : Maybe Token
13 | , slug : Slug
14 | , onResponse : Result (Api.Error ()) Article -> msg
15 | }
16 |
17 |
18 | getArticle : Url -> Options msg -> Cmd msg
19 | getArticle baseUrl { maybeToken, slug, onResponse } =
20 | Api.get
21 | { maybeToken = maybeToken
22 | , url = Api.buildUrl baseUrl [ "articles", Slug.toString slug ] [] []
23 | , onResponse = onResponse
24 | , decoder = decoder
25 | }
26 |
27 |
28 | decoder : JD.Decoder Article
29 | decoder =
30 | JD.field "article" Article.decoder
31 |
--------------------------------------------------------------------------------
/src/Page/Error.elm:
--------------------------------------------------------------------------------
1 | module Page.Error exposing (ViewOptions, view)
2 |
3 | import Browser as B
4 | import Html as H
5 | import Html.Attributes as HA
6 |
7 |
8 | type alias ViewOptions =
9 | { title : String
10 | , message : String
11 | }
12 |
13 |
14 | view : ViewOptions -> B.Document msg
15 | view { title, message } =
16 | { title = title
17 | , body =
18 | [ H.div
19 | [ HA.class "error-page" ]
20 | [ H.div
21 | [ HA.class "container page" ]
22 | [ H.div
23 | [ HA.class "row" ]
24 | [ H.div
25 | [ HA.class "col-xs-12" ]
26 | [ H.h1 [] [ H.text title ]
27 | , H.p [] [ H.text message ]
28 | ]
29 | ]
30 | ]
31 | ]
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/src/View/Register.elm:
--------------------------------------------------------------------------------
1 | module View.Register exposing (ViewOptions, view)
2 |
3 | import Data.Route as Route
4 | import Html as H
5 | import Html.Attributes as HA
6 | import View.AuthErrors as AuthErrors
7 | import View.RegisterForm as RegisterForm
8 |
9 |
10 | type alias ViewOptions msg =
11 | { form : RegisterForm.ViewOptions msg
12 | , errorMessages : List String
13 | }
14 |
15 |
16 | view : ViewOptions msg -> H.Html msg
17 | view { form, errorMessages } =
18 | H.div []
19 | [ H.h1
20 | [ HA.class "text-xs-center" ]
21 | [ H.text "Sign up" ]
22 | , H.p
23 | [ HA.class "text-xs-center" ]
24 | [ H.a
25 | [ HA.href <| Route.toString Route.Login ]
26 | [ H.text "Have an account?" ]
27 | ]
28 | , AuthErrors.view errorMessages
29 | , RegisterForm.view form
30 | ]
31 |
--------------------------------------------------------------------------------
/src/Api/GetComments.elm:
--------------------------------------------------------------------------------
1 | module Api.GetComments exposing (Options, getComments)
2 |
3 | import Api
4 | import Data.Comments as Comments exposing (Comments)
5 | import Data.Slug as Slug exposing (Slug)
6 | import Data.Token exposing (Token)
7 | import Json.Decode as JD
8 | import Url exposing (Url)
9 |
10 |
11 | type alias Options msg =
12 | { maybeToken : Maybe Token
13 | , slug : Slug
14 | , onResponse : Result (Api.Error ()) Comments -> msg
15 | }
16 |
17 |
18 | getComments : Url -> Options msg -> Cmd msg
19 | getComments baseUrl { maybeToken, slug, onResponse } =
20 | Api.get
21 | { maybeToken = maybeToken
22 | , url = Api.buildUrl baseUrl [ "articles", Slug.toString slug, "comments" ] [] []
23 | , onResponse = onResponse
24 | , decoder = decoder
25 | }
26 |
27 |
28 | decoder : JD.Decoder Comments
29 | decoder =
30 | JD.field "comments" Comments.decoder
31 |
--------------------------------------------------------------------------------
/src/View/Settings.elm:
--------------------------------------------------------------------------------
1 | module View.Settings exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 | import View.AuthErrors as AuthErrors
7 | import View.SettingsForm as SettingsForm
8 |
9 |
10 | type alias ViewOptions msg =
11 | { form : SettingsForm.ViewOptions msg
12 | , errorMessages : List String
13 | , onLogout : msg
14 | }
15 |
16 |
17 | view : ViewOptions msg -> H.Html msg
18 | view { form, errorMessages, onLogout } =
19 | H.div []
20 | [ H.h1
21 | [ HA.class "text-xs-center" ]
22 | [ H.text "Your Settings" ]
23 | , AuthErrors.view errorMessages
24 | , SettingsForm.view form
25 | , H.hr [] []
26 | , H.button
27 | [ HA.class "btn btn-outline-danger"
28 | , HA.type_ "button"
29 | , HE.onClick onLogout
30 | ]
31 | [ H.text "Or click here to logout." ]
32 | ]
33 |
--------------------------------------------------------------------------------
/src/View/Textarea.elm:
--------------------------------------------------------------------------------
1 | module View.Textarea exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 |
7 |
8 | type alias ViewOptions msg =
9 | { name : String
10 | , placeholder : String
11 | , rows : Int
12 | , value : String
13 | , isDisabled : Bool
14 | , onInput : String -> msg
15 | }
16 |
17 |
18 | view : ViewOptions msg -> H.Html msg
19 | view { name, placeholder, rows, value, isDisabled, onInput } =
20 | H.fieldset
21 | [ HA.class "form-group" ]
22 | [ H.textarea
23 | [ HA.class "form-control form-control-lg"
24 | , HA.name name
25 | , HA.placeholder placeholder
26 | , HA.rows rows
27 | , HA.value value
28 | , if isDisabled then
29 | HA.disabled True
30 |
31 | else
32 | HE.onInput onInput
33 | ]
34 | []
35 | ]
36 |
--------------------------------------------------------------------------------
/prototype/head.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Head
17 |
18 | The <head> element includes all the metadata for a page, including the title, description, and links to stylesheets and scripts.
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/View/Input.elm:
--------------------------------------------------------------------------------
1 | module View.Input exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 |
7 |
8 | type alias ViewOptions msg =
9 | { name : String
10 | , type_ : String
11 | , placeholder : String
12 | , value : String
13 | , isDisabled : Bool
14 | , onInput : String -> msg
15 | }
16 |
17 |
18 | view : ViewOptions msg -> H.Html msg
19 | view { name, type_, placeholder, value, isDisabled, onInput } =
20 | H.fieldset
21 | [ HA.class "form-group" ]
22 | [ H.input
23 | [ HA.class "form-control form-control-lg"
24 | , HA.id name
25 | , HA.name name
26 | , HA.type_ type_
27 | , HA.placeholder placeholder
28 | , HA.value value
29 | , if isDisabled then
30 | HA.disabled True
31 |
32 | else
33 | HE.onInput onInput
34 | ]
35 | []
36 | ]
37 |
--------------------------------------------------------------------------------
/src/View/ArticleContent.elm:
--------------------------------------------------------------------------------
1 | module View.ArticleContent exposing (ViewOptions, view)
2 |
3 | import Data.Tag as Tag exposing (Tag)
4 | import Html as H
5 | import Html.Attributes as HA
6 | import Markdown
7 |
8 |
9 | type alias ViewOptions =
10 | { description : String
11 | , body : String
12 | , tags : List Tag
13 | }
14 |
15 |
16 | view : ViewOptions -> H.Html msg
17 | view { description, body, tags } =
18 | H.div
19 | [ HA.class "article-content" ]
20 | [ H.p [] [ H.text description ]
21 | , viewMarkdown body
22 | , viewTags tags
23 | ]
24 |
25 |
26 | viewMarkdown : String -> H.Html msg
27 | viewMarkdown =
28 | Markdown.toHtml []
29 |
30 |
31 | viewTags : List Tag -> H.Html msg
32 | viewTags =
33 | List.map
34 | (\tag ->
35 | H.li
36 | [ HA.class "tag-default tag-pill tag-outline" ]
37 | [ H.text <| Tag.toString tag ]
38 | )
39 | >> H.ul [ HA.class "tag-list" ]
40 |
--------------------------------------------------------------------------------
/sandbox/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Conduit Sandbox
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/Lib/OrderedSet.elm:
--------------------------------------------------------------------------------
1 | module Lib.OrderedSet exposing
2 | ( OrderedSet
3 | , add
4 | , empty
5 | , fromList
6 | , remove
7 | , toList
8 | )
9 |
10 | --
11 | -- Properties:
12 | --
13 | -- 1. The insertion order is preserved.
14 | -- 2. The elements are unique.
15 | --
16 |
17 |
18 | type OrderedSet a
19 | = OrderedSet (List a)
20 |
21 |
22 | empty : OrderedSet a
23 | empty =
24 | OrderedSet []
25 |
26 |
27 | fromList : List a -> OrderedSet a
28 | fromList =
29 | List.foldl add empty
30 |
31 |
32 | add : a -> OrderedSet a -> OrderedSet a
33 | add item ((OrderedSet list) as orderedSet) =
34 | if List.member item list then
35 | orderedSet
36 |
37 | else
38 | OrderedSet (item :: list)
39 |
40 |
41 | remove : a -> OrderedSet a -> OrderedSet a
42 | remove item (OrderedSet list) =
43 | list
44 | |> List.filter ((/=) item)
45 | |> OrderedSet
46 |
47 |
48 | toList : OrderedSet a -> List a
49 | toList (OrderedSet list) =
50 | List.reverse list
51 |
--------------------------------------------------------------------------------
/src/Data/Comments.elm:
--------------------------------------------------------------------------------
1 | module Data.Comments exposing (Comments, add, decoder, remove, toList)
2 |
3 | import Data.Comment as Comment exposing (Comment)
4 | import Json.Decode as JD
5 | import Lib.Basics as Basics
6 |
7 |
8 | type Comments
9 | = Comments (List Comment)
10 |
11 |
12 | decoder : JD.Decoder Comments
13 | decoder =
14 | Comment.decoder
15 | |> JD.list
16 | |> JD.map (Comments << sort)
17 |
18 |
19 | sort : List Comment -> List Comment
20 | sort =
21 | List.sortWith
22 | (\comment1 comment2 ->
23 | Comment.compare comment1 comment2
24 | |> Basics.reverseOrder
25 | )
26 |
27 |
28 | add : Comment -> Comments -> Comments
29 | add comment (Comments comments) =
30 | Comments (comment :: comments)
31 |
32 |
33 | remove : Int -> Comments -> Comments
34 | remove id (Comments comments) =
35 | comments
36 | |> List.filter (.id >> (/=) id)
37 | |> Comments
38 |
39 |
40 | toList : Comments -> List Comment
41 | toList (Comments comments) =
42 | comments
43 |
--------------------------------------------------------------------------------
/elm.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "application",
3 | "source-directories": [
4 | "src"
5 | ],
6 | "elm-version": "0.19.1",
7 | "dependencies": {
8 | "direct": {
9 | "NoRedInk/elm-json-decode-pipeline": "1.0.1",
10 | "elm/browser": "1.0.2",
11 | "elm/core": "1.0.5",
12 | "elm/html": "1.0.0",
13 | "elm/http": "2.0.0",
14 | "elm/json": "1.1.3",
15 | "elm/time": "1.0.0",
16 | "elm/url": "1.0.0",
17 | "elm-explorations/markdown": "1.0.0",
18 | "justinmimbs/date": "4.0.1",
19 | "rtfeldman/elm-iso8601-date-strings": "1.1.4"
20 | },
21 | "indirect": {
22 | "elm/bytes": "1.0.8",
23 | "elm/file": "1.0.5",
24 | "elm/parser": "1.1.0",
25 | "elm/virtual-dom": "1.0.3"
26 | }
27 | },
28 | "test-dependencies": {
29 | "direct": {
30 | "elm-explorations/test": "2.2.0"
31 | },
32 | "indirect": {
33 | "elm/random": "1.0.0"
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Data/Config.elm:
--------------------------------------------------------------------------------
1 | module Data.Config exposing (BadToken(..), Config, decoder)
2 |
3 | import Data.Token as Token exposing (Token)
4 | import Json.Decode as JD
5 | import Lib.Json.Decode as JD
6 | import Url exposing (Url)
7 |
8 |
9 | type alias Config =
10 | { apiUrl : Url
11 | , resultMaybeToken : Result BadToken (Maybe Token)
12 | }
13 |
14 |
15 | type BadToken
16 | = BadToken JD.Error
17 |
18 |
19 | decoder : JD.Decoder Config
20 | decoder =
21 | JD.map2 Config
22 | (JD.field "apiUrl" JD.url)
23 | (JD.field "maybeToken" tokenDecoder)
24 |
25 |
26 | tokenDecoder : JD.Decoder (Result BadToken (Maybe Token))
27 | tokenDecoder =
28 | JD.oneOf
29 | [ JD.map Ok (JD.nullable Token.decoder)
30 |
31 | --
32 | -- NOTE: It's possible that a token exists but it has been corrupted in
33 | -- some way. If that's the case we want to recognize the error but we
34 | -- don't want it to result in a decoder error. That way it's easier to
35 | -- detect when the situation occurs.
36 | --
37 | , JD.map (Err << BadToken << JD.Failure "Bad token") JD.value
38 | ]
39 |
--------------------------------------------------------------------------------
/src/Lib/NonEmptyString.elm:
--------------------------------------------------------------------------------
1 | module Lib.NonEmptyString exposing
2 | ( NonEmptyString
3 | , decoder
4 | , encode
5 | , fromString
6 | , toString
7 | )
8 |
9 | import Json.Decode as JD
10 | import Json.Encode as JE
11 |
12 |
13 | type NonEmptyString
14 | = NonEmptyString String
15 |
16 |
17 | fromString : String -> Maybe NonEmptyString
18 | fromString s =
19 | let
20 | t =
21 | String.trim s
22 | in
23 | if String.isEmpty t then
24 | Nothing
25 |
26 | else
27 | Just <| NonEmptyString t
28 |
29 |
30 | decoder : JD.Decoder NonEmptyString
31 | decoder =
32 | JD.string
33 | |> JD.andThen
34 | (\s ->
35 | case fromString s of
36 | Just t ->
37 | JD.succeed t
38 |
39 | Nothing ->
40 | JD.fail <| "Expected a non-empty string: '" ++ s ++ "'"
41 | )
42 |
43 |
44 | encode : NonEmptyString -> JE.Value
45 | encode (NonEmptyString t) =
46 | JE.string t
47 |
48 |
49 | toString : NonEmptyString -> String
50 | toString (NonEmptyString t) =
51 | t
52 |
--------------------------------------------------------------------------------
/src/Lib/Validation.elm:
--------------------------------------------------------------------------------
1 | module Lib.Validation exposing
2 | ( Validation
3 | , apply
4 | , fail
5 | , succeed
6 | , withValidation
7 | )
8 |
9 |
10 | type Validation a
11 | = Success a
12 | | Failure (List String)
13 |
14 |
15 | succeed : a -> Validation a
16 | succeed =
17 | Success
18 |
19 |
20 | fail : String -> Validation a
21 | fail message =
22 | Failure [ message ]
23 |
24 |
25 | apply : Validation a -> Validation (a -> b) -> Validation b
26 | apply va vf =
27 | case ( vf, va ) of
28 | ( Success f, Success a ) ->
29 | Success (f a)
30 |
31 | ( Failure es, Success _ ) ->
32 | Failure es
33 |
34 | ( Success _, Failure es ) ->
35 | Failure es
36 |
37 | ( Failure es1, Failure es2 ) ->
38 | Failure (es1 ++ es2)
39 |
40 |
41 | withValidation :
42 | { onSuccess : a -> b
43 | , onFailure : List String -> b
44 | }
45 | -> Validation a
46 | -> b
47 | withValidation { onSuccess, onFailure } va =
48 | case va of
49 | Success a ->
50 | onSuccess a
51 |
52 | Failure es ->
53 | onFailure es
54 |
--------------------------------------------------------------------------------
/src/Api/CreateArticle.elm:
--------------------------------------------------------------------------------
1 | module Api.CreateArticle exposing (Options, createArticle)
2 |
3 | import Api
4 | import Data.Article as Article exposing (Article)
5 | import Data.Token exposing (Token)
6 | import Http
7 | import Json.Decode as JD
8 | import Json.Encode as JE
9 | import Url exposing (Url)
10 |
11 |
12 | type alias Options msg =
13 | { token : Token
14 | , articleFields : Article.Fields
15 | , onResponse : Result (Api.Error (List String)) Article -> msg
16 | }
17 |
18 |
19 | createArticle : Url -> Options msg -> Cmd msg
20 | createArticle baseUrl { token, articleFields, onResponse } =
21 | Api.post
22 | { maybeToken = Just token
23 | , url = Api.buildUrl baseUrl [ "articles" ] [] []
24 | , body = Http.jsonBody <| encodeInput articleFields
25 | , onResponse = onResponse
26 | , decoder = decoder
27 | , errorsDecoder = Api.formErrorsDecoder Article.fieldNames
28 | }
29 |
30 |
31 | encodeInput : Article.Fields -> JE.Value
32 | encodeInput fields =
33 | JE.object [ ( "article", Article.encode fields ) ]
34 |
35 |
36 | decoder : JD.Decoder Article
37 | decoder =
38 | JD.field "article" Article.decoder
39 |
--------------------------------------------------------------------------------
/src/View/Column.elm:
--------------------------------------------------------------------------------
1 | module View.Column exposing (Size(..), viewDouble, viewSingle)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 |
6 |
7 | type Size
8 | = Large
9 | | Medium
10 | | Small
11 | | ExtraSmall
12 |
13 |
14 | viewSingle : Size -> List (H.Html msg) -> H.Html msg
15 | viewSingle size column =
16 | let
17 | classNames =
18 | case size of
19 | Large ->
20 | "col-xs-12"
21 |
22 | Medium ->
23 | "col-md-10 offset-md-1 col-xs-12"
24 |
25 | Small ->
26 | "col-md-8 offset-md-2 col-xs-12"
27 |
28 | ExtraSmall ->
29 | "col-md-6 offset-md-3 col-xs-12"
30 | in
31 | H.div
32 | [ HA.class "row" ]
33 | [ H.div [ HA.class classNames ] column
34 | ]
35 |
36 |
37 | viewDouble :
38 | { left : List (H.Html msg)
39 | , right : List (H.Html msg)
40 | }
41 | -> H.Html msg
42 | viewDouble { left, right } =
43 | H.div
44 | [ HA.class "row" ]
45 | [ H.div [ HA.class "col-md-9" ] left
46 | , H.div [ HA.class "col-md-3" ] right
47 | ]
48 |
--------------------------------------------------------------------------------
/src/Data/Comment.elm:
--------------------------------------------------------------------------------
1 | module Data.Comment exposing (Comment, Commenter, compare, decoder)
2 |
3 | import Data.Timestamp as Timestamp exposing (Timestamp)
4 | import Data.Username as Username exposing (Username)
5 | import Json.Decode as JD
6 | import Lib.Json.Decode as JD
7 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
8 | import Url exposing (Url)
9 |
10 |
11 | type alias Comment =
12 | { id : Int
13 | , createdAt : Timestamp
14 | , body : NonEmptyString
15 | , commenter : Commenter
16 | }
17 |
18 |
19 | type alias Commenter =
20 | { username : Username
21 | , imageUrl : Url
22 | }
23 |
24 |
25 | decoder : JD.Decoder Comment
26 | decoder =
27 | JD.map4 Comment
28 | (JD.field "id" JD.int)
29 | (JD.field "createdAt" Timestamp.decoder)
30 | (JD.field "body" NonEmptyString.decoder)
31 | (JD.field "author" <|
32 | JD.map2 Commenter
33 | (JD.field "username" Username.decoder)
34 | (JD.field "image" JD.url)
35 | )
36 |
37 |
38 | compare : Comment -> Comment -> Order
39 | compare comment1 comment2 =
40 | Timestamp.compare comment1.createdAt comment2.createdAt
41 |
--------------------------------------------------------------------------------
/src/Page/NotAuthorized.elm:
--------------------------------------------------------------------------------
1 | module Page.NotAuthorized exposing (view)
2 |
3 | import Browser as B
4 | import Data.Route as Route
5 | import Html as H
6 | import Html.Attributes as HA
7 | import View.Column as Column
8 | import View.Layout as Layout
9 | import View.Navigation as Navigation
10 |
11 |
12 | view : B.Document msg
13 | view =
14 | { title = "Not Authorized"
15 | , body =
16 | [ Layout.view
17 | { name = "not-authorized-page"
18 | , role = Navigation.guest
19 | , maybeHeader = Nothing
20 | }
21 | [ Column.viewSingle Column.Large
22 | [ H.h1 [] [ H.text "Not Authorized" ]
23 | , H.p []
24 | [ H.text "Please "
25 | , H.a
26 | [ HA.href <| Route.toString Route.Login ]
27 | [ H.text "Sign in" ]
28 | , H.text " or "
29 | , H.a
30 | [ HA.href <| Route.toString Route.Register ]
31 | [ H.text "Sign up" ]
32 | , H.text " to view this page."
33 | ]
34 | ]
35 | ]
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------
/prototype/footer.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Footer
17 |
18 | The footer is absolutely positioned at the bottom of the page.
19 |
20 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/Api/UpdateArticle.elm:
--------------------------------------------------------------------------------
1 | module Api.UpdateArticle exposing (Options, updateArticle)
2 |
3 | import Api
4 | import Data.Article as Article exposing (Article)
5 | import Data.Slug as Slug exposing (Slug)
6 | import Data.Token exposing (Token)
7 | import Http
8 | import Json.Decode as JD
9 | import Json.Encode as JE
10 | import Url exposing (Url)
11 |
12 |
13 | type alias Options msg =
14 | { token : Token
15 | , slug : Slug
16 | , articleFields : Article.Fields
17 | , onResponse : Result (Api.Error (List String)) Article -> msg
18 | }
19 |
20 |
21 | updateArticle : Url -> Options msg -> Cmd msg
22 | updateArticle baseUrl { token, slug, articleFields, onResponse } =
23 | Api.put
24 | { token = token
25 | , url = Api.buildUrl baseUrl [ "articles", Slug.toString slug ] [] []
26 | , body = Http.jsonBody <| encodeInput articleFields
27 | , onResponse = onResponse
28 | , decoder = decoder
29 | , errorsDecoder = Api.formErrorsDecoder Article.fieldNames
30 | }
31 |
32 |
33 | encodeInput : Article.Fields -> JE.Value
34 | encodeInput fields =
35 | JE.object [ ( "article", Article.encode fields ) ]
36 |
37 |
38 | decoder : JD.Decoder Article
39 | decoder =
40 | JD.field "article" Article.decoder
41 |
--------------------------------------------------------------------------------
/src/Api/GetProfile.elm:
--------------------------------------------------------------------------------
1 | module Api.GetProfile exposing (Options, Profile, getProfile)
2 |
3 | import Api
4 | import Data.Token exposing (Token)
5 | import Data.Username as Username exposing (Username)
6 | import Json.Decode as JD
7 | import Lib.Json.Decode as JD
8 | import Url exposing (Url)
9 |
10 |
11 | type alias Options msg =
12 | { maybeToken : Maybe Token
13 | , username : Username
14 | , onResponse : Result (Api.Error ()) Profile -> msg
15 | }
16 |
17 |
18 | getProfile : Url -> Options msg -> Cmd msg
19 | getProfile baseUrl { maybeToken, username, onResponse } =
20 | Api.get
21 | { maybeToken = maybeToken
22 | , url = Api.buildUrl baseUrl [ "profiles", Username.toString username ] [] []
23 | , onResponse = onResponse
24 | , decoder = decoder
25 | }
26 |
27 |
28 | type alias Profile =
29 | { username : Username
30 | , imageUrl : Url
31 | , bio : String
32 | , isFollowing : Bool
33 | }
34 |
35 |
36 | decoder : JD.Decoder Profile
37 | decoder =
38 | JD.field "profile" <|
39 | JD.map4 Profile
40 | (JD.field "username" Username.decoder)
41 | (JD.field "image" JD.url)
42 | (JD.field "bio" JD.nullableString)
43 | (JD.field "following" JD.bool)
44 |
--------------------------------------------------------------------------------
/src/Page/NotFound.elm:
--------------------------------------------------------------------------------
1 | module Page.NotFound exposing (ViewOptions, view)
2 |
3 | import Browser as B
4 | import Data.Viewer as Viewer exposing (Viewer)
5 | import Html as H
6 | import View.Column as Column
7 | import View.Layout as Layout
8 | import View.Navigation as Navigation
9 |
10 |
11 | type alias ViewOptions msg =
12 | { viewer : Viewer
13 | , onLogout : msg
14 | }
15 |
16 |
17 | view : ViewOptions msg -> B.Document msg
18 | view { viewer, onLogout } =
19 | { title = "Not Found"
20 | , body =
21 | [ Layout.view
22 | { name = "not-found-page"
23 | , role =
24 | case viewer of
25 | Viewer.Guest ->
26 | Navigation.guest
27 |
28 | Viewer.User { username, imageUrl } ->
29 | Navigation.user
30 | { username = username
31 | , imageUrl = imageUrl
32 | , onLogout = onLogout
33 | }
34 | , maybeHeader = Nothing
35 | }
36 | [ Column.viewSingle Column.Large
37 | [ H.text "The page you are looking for does not exist."
38 | ]
39 | ]
40 | ]
41 | }
42 |
--------------------------------------------------------------------------------
/src/Api/ToggleFollow.elm:
--------------------------------------------------------------------------------
1 | module Api.ToggleFollow exposing (Options, toggleFollow)
2 |
3 | import Api
4 | import Data.Token exposing (Token)
5 | import Data.Username as Username exposing (Username)
6 | import Http
7 | import Json.Decode as JD
8 | import Lib.Either as Either
9 | import Url exposing (Url)
10 |
11 |
12 | type alias Options msg =
13 | { token : Token
14 | , username : Username
15 | , isFollowing : Bool
16 | , onResponse : Result (Api.Error ()) Bool -> msg
17 | }
18 |
19 |
20 | toggleFollow : Url -> Options msg -> Cmd msg
21 | toggleFollow baseUrl { token, username, isFollowing, onResponse } =
22 | Api.request
23 | { method =
24 | if isFollowing then
25 | Api.POST
26 |
27 | else
28 | Api.DELETE
29 | , maybeToken = Just token
30 | , url =
31 | Api.buildUrl
32 | baseUrl
33 | [ "profiles", Username.toString username, "follow" ]
34 | []
35 | []
36 | , body = Http.emptyBody
37 | , onResponse = onResponse
38 | , eitherDefaultOrDecoder = Either.Right decoder
39 | , errorsDecoder = Api.emptyErrorsDecoder
40 | }
41 |
42 |
43 | decoder : JD.Decoder Bool
44 | decoder =
45 | JD.at [ "profile", "following" ] JD.bool
46 |
--------------------------------------------------------------------------------
/src/Api/CreateComment.elm:
--------------------------------------------------------------------------------
1 | module Api.CreateComment exposing (Options, createComment)
2 |
3 | import Api
4 | import Data.Comment as Comment exposing (Comment)
5 | import Data.Slug as Slug exposing (Slug)
6 | import Data.Token exposing (Token)
7 | import Http
8 | import Json.Decode as JD
9 | import Json.Encode as JE
10 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
11 | import Url exposing (Url)
12 |
13 |
14 | type alias Options msg =
15 | { token : Token
16 | , slug : Slug
17 | , comment : NonEmptyString
18 | , onResponse : Result (Api.Error ()) Comment -> msg
19 | }
20 |
21 |
22 | createComment : Url -> Options msg -> Cmd msg
23 | createComment baseUrl { token, slug, comment, onResponse } =
24 | Api.post
25 | { maybeToken = Just token
26 | , url = Api.buildUrl baseUrl [ "articles", Slug.toString slug, "comments" ] [] []
27 | , body = Http.jsonBody <| encodeInput comment
28 | , onResponse = onResponse
29 | , decoder = decoder
30 | , errorsDecoder = Api.emptyErrorsDecoder
31 | }
32 |
33 |
34 | encodeInput : NonEmptyString -> JE.Value
35 | encodeInput comment =
36 | JE.object
37 | [ ( "comment"
38 | , JE.object [ ( "body", NonEmptyString.encode comment ) ]
39 | )
40 | ]
41 |
42 |
43 | decoder : JD.Decoder Comment
44 | decoder =
45 | JD.field "comment" Comment.decoder
46 |
--------------------------------------------------------------------------------
/review/elm.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "application",
3 | "source-directories": [
4 | "src"
5 | ],
6 | "elm-version": "0.19.1",
7 | "dependencies": {
8 | "direct": {
9 | "elm/core": "1.0.5",
10 | "elm/json": "1.1.3",
11 | "elm/project-metadata-utils": "1.0.2",
12 | "jfmengels/elm-review": "2.13.1",
13 | "jfmengels/elm-review-code-style": "1.1.4",
14 | "jfmengels/elm-review-common": "1.3.3",
15 | "jfmengels/elm-review-debug": "1.0.8",
16 | "jfmengels/elm-review-documentation": "2.0.4",
17 | "jfmengels/elm-review-simplify": "2.1.3",
18 | "jfmengels/elm-review-unused": "1.2.0",
19 | "stil4m/elm-syntax": "7.3.2"
20 | },
21 | "indirect": {
22 | "elm/bytes": "1.0.8",
23 | "elm/html": "1.0.0",
24 | "elm/parser": "1.1.0",
25 | "elm/random": "1.0.0",
26 | "elm/regex": "1.0.0",
27 | "elm/time": "1.0.0",
28 | "elm/virtual-dom": "1.0.3",
29 | "elm-explorations/test": "2.2.0",
30 | "miniBill/elm-unicode": "1.1.1",
31 | "pzp1997/assoc-list": "1.0.0",
32 | "rtfeldman/elm-hex": "1.0.0",
33 | "stil4m/structured-writer": "1.0.3"
34 | }
35 | },
36 | "test-dependencies": {
37 | "direct": {},
38 | "indirect": {}
39 | }
40 | }
--------------------------------------------------------------------------------
/src/Api/Login.elm:
--------------------------------------------------------------------------------
1 | module Api.Login exposing (Options, login)
2 |
3 | import Api
4 | import Data.Email as Email exposing (Email)
5 | import Data.Password as Password exposing (Password)
6 | import Data.User as User exposing (User)
7 | import Http
8 | import Json.Decode as JD
9 | import Json.Encode as JE
10 | import Url exposing (Url)
11 |
12 |
13 | type alias Options msg =
14 | { email : Email
15 | , password : Password
16 | , onResponse : Result (Api.Error (List String)) User -> msg
17 | }
18 |
19 |
20 | login : Url -> Options msg -> Cmd msg
21 | login baseUrl { email, password, onResponse } =
22 | Api.post
23 | { maybeToken = Nothing
24 | , url = Api.buildUrl baseUrl [ "users", "login" ] [] []
25 | , body = Http.jsonBody <| encodeInput email password
26 | , onResponse = onResponse
27 | , decoder = decoder
28 | , errorsDecoder =
29 | Api.formErrorsDecoder
30 | [ "email"
31 | , "password"
32 | , "email or password"
33 | ]
34 | }
35 |
36 |
37 | encodeInput : Email -> Password -> JE.Value
38 | encodeInput email password =
39 | JE.object
40 | [ ( "user"
41 | , JE.object
42 | [ ( "email", Email.encode email )
43 | , ( "password", Password.encode password )
44 | ]
45 | )
46 | ]
47 |
48 |
49 | decoder : JD.Decoder User
50 | decoder =
51 | JD.field "user" User.decoder
52 |
--------------------------------------------------------------------------------
/src/View/FavouriteButton.elm:
--------------------------------------------------------------------------------
1 | module View.FavouriteButton exposing (ViewOptions, view)
2 |
3 | import Data.Total as Total exposing (Total)
4 | import Html as H
5 | import Html.Attributes as HA
6 | import Html.Events as HE
7 |
8 |
9 | type alias ViewOptions msg =
10 | { isFavourite : Bool
11 | , totalFavourites : Total
12 | , isDisabled : Bool
13 | , onFavourite : msg
14 | , onUnfavourite : msg
15 | }
16 |
17 |
18 | view : ViewOptions msg -> H.Html msg
19 | view { isFavourite, totalFavourites, isDisabled, onFavourite, onUnfavourite } =
20 | let
21 | { action, buttonClass, onClick } =
22 | if isFavourite then
23 | { action = "Unfavourite"
24 | , buttonClass = "btn-primary"
25 | , onClick = onUnfavourite
26 | }
27 |
28 | else
29 | { action = "Favourite"
30 | , buttonClass = "btn-outline-primary"
31 | , onClick = onFavourite
32 | }
33 | in
34 | H.button
35 | [ HA.class "btn btn-sm"
36 | , HA.class buttonClass
37 | , if isDisabled then
38 | HA.disabled True
39 |
40 | else
41 | HE.onClick onClick
42 | ]
43 | [ H.i
44 | [ HA.class "ion-heart" ]
45 | []
46 | , H.text <| "\u{00A0} " ++ action ++ " Article "
47 | , H.span
48 | [ HA.class "counter" ]
49 | [ H.text <| "(" ++ Total.toString totalFavourites ++ ")" ]
50 | ]
51 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 Dwayne Crooks
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are met:
5 |
6 | 1. Redistributions of source code must retain the above copyright notice, this
7 | list of conditions and the following disclaimer.
8 |
9 | 2. Redistributions in binary form must reproduce the above copyright notice,
10 | this list of conditions and the following disclaimer in the documentation
11 | and/or other materials provided with the distribution.
12 |
13 | 3. Neither the name of the copyright holder nor the names of its contributors
14 | may be used to endorse or promote products derived from this software without
15 | specific prior written permission.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27 |
--------------------------------------------------------------------------------
/src/View/LoginForm.elm:
--------------------------------------------------------------------------------
1 | module View.LoginForm exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 | import View.Input as Input
7 |
8 |
9 | type alias ViewOptions msg =
10 | { email : String
11 | , password : String
12 | , isDisabled : Bool
13 | , onInputEmail : String -> msg
14 | , onInputPassword : String -> msg
15 | , onSubmit : msg
16 | }
17 |
18 |
19 | view : ViewOptions msg -> H.Html msg
20 | view { email, password, isDisabled, onInputEmail, onInputPassword, onSubmit } =
21 | let
22 | attrs =
23 | if isDisabled then
24 | []
25 |
26 | else
27 | [ HE.onSubmit onSubmit ]
28 | in
29 | H.form attrs
30 | [ Input.view
31 | { name = "email"
32 | , type_ = "text"
33 | , placeholder = "Email"
34 | , value = email
35 | , isDisabled = isDisabled
36 | , onInput = onInputEmail
37 | }
38 | , Input.view
39 | { name = "password"
40 | , type_ = "password"
41 | , placeholder = "Password"
42 | , value = password
43 | , isDisabled = isDisabled
44 | , onInput = onInputPassword
45 | }
46 | , H.button
47 | [ HA.class "btn btn-lg btn-primary pull-xs-right"
48 | , HA.type_ "submit"
49 | , HA.disabled isDisabled
50 | ]
51 | [ H.text "Sign in" ]
52 | ]
53 |
--------------------------------------------------------------------------------
/src/View/Tabs.elm:
--------------------------------------------------------------------------------
1 | module View.Tabs exposing (Tab, ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 |
7 |
8 | type alias ViewOptions tab msg =
9 | { name : String
10 | , tabs : List (Tab tab)
11 | , activeTab : tab
12 | , isDisabled : Bool
13 | , onSwitch : tab -> msg
14 | }
15 |
16 |
17 | type alias Tab tab =
18 | { id : tab
19 | , title : String
20 | }
21 |
22 |
23 | view : ViewOptions tab msg -> H.Html msg
24 | view { name, tabs, activeTab, isDisabled, onSwitch } =
25 | let
26 | className =
27 | name ++ "-toggle"
28 |
29 | viewTabs =
30 | List.map (viewTab activeTab isDisabled onSwitch) tabs
31 | in
32 | H.div
33 | [ HA.class className ]
34 | [ H.ul
35 | [ HA.class "nav nav-pills outline-active" ]
36 | viewTabs
37 | ]
38 |
39 |
40 | viewTab : tab -> Bool -> (tab -> msg) -> Tab tab -> H.Html msg
41 | viewTab activeTab isDisabled onSwitch { id, title } =
42 | let
43 | baseAttrs =
44 | [ HA.class "nav-link"
45 | ]
46 |
47 | extraAttrs =
48 | if id == activeTab then
49 | [ HA.class "active"
50 | ]
51 |
52 | else
53 | [ if isDisabled then
54 | HA.disabled True
55 |
56 | else
57 | HE.onClick (onSwitch id)
58 | ]
59 |
60 | attrs =
61 | baseAttrs ++ extraAttrs
62 | in
63 | H.li
64 | [ HA.class "nav-item" ]
65 | [ H.button attrs [ H.text title ]
66 | ]
67 |
--------------------------------------------------------------------------------
/src/Api/Register.elm:
--------------------------------------------------------------------------------
1 | module Api.Register exposing (Options, register)
2 |
3 | import Api
4 | import Data.Email as Email exposing (Email)
5 | import Data.Password as Password exposing (Password)
6 | import Data.User as User exposing (User)
7 | import Data.Username as Username exposing (Username)
8 | import Http
9 | import Json.Decode as JD
10 | import Json.Encode as JE
11 | import Url exposing (Url)
12 |
13 |
14 | type alias Options msg =
15 | { username : Username
16 | , email : Email
17 | , password : Password
18 | , onResponse : Result (Api.Error (List String)) User -> msg
19 | }
20 |
21 |
22 | register : Url -> Options msg -> Cmd msg
23 | register baseUrl { username, email, password, onResponse } =
24 | Api.post
25 | { maybeToken = Nothing
26 | , url = Api.buildUrl baseUrl [ "users" ] [] []
27 | , body = Http.jsonBody <| encodeInput username email password
28 | , onResponse = onResponse
29 | , decoder = decoder
30 | , errorsDecoder =
31 | Api.formErrorsDecoder
32 | [ "username"
33 | , "email"
34 | , "password"
35 | ]
36 | }
37 |
38 |
39 | encodeInput : Username -> Email -> Password -> JE.Value
40 | encodeInput username email password =
41 | JE.object
42 | [ ( "user"
43 | , JE.object
44 | [ ( "username", Username.encode username )
45 | , ( "email", Email.encode email )
46 | , ( "password", Password.encode password )
47 | ]
48 | )
49 | ]
50 |
51 |
52 | decoder : JD.Decoder User
53 | decoder =
54 | JD.field "user" User.decoder
55 |
--------------------------------------------------------------------------------
/src/View/Sidebar.elm:
--------------------------------------------------------------------------------
1 | module View.Sidebar exposing (TagsOptions, ViewOptions(..), view)
2 |
3 | import Data.Tag as Tag exposing (Tag)
4 | import Html as H
5 | import Html.Attributes as HA
6 | import Html.Events as HE
7 | import Lib.Html.Attributes as HA
8 |
9 |
10 | type ViewOptions msg
11 | = Loading
12 | | Tags (TagsOptions msg)
13 | | Error String
14 |
15 |
16 | type alias TagsOptions msg =
17 | { tags : List Tag
18 | , activeTag : Maybe Tag
19 | , onClick : Tag -> msg
20 | }
21 |
22 |
23 | view : ViewOptions msg -> H.Html msg
24 | view options =
25 | H.div
26 | [ HA.class "sidebar" ]
27 | [ H.p [] [ H.text "Popular Tags" ]
28 | , H.div [ HA.class "tag-list" ] <|
29 | case options of
30 | Loading ->
31 | [ H.text "Loading tags..." ]
32 |
33 | Tags { tags, activeTag, onClick } ->
34 | List.map
35 | (\tag ->
36 | let
37 | attrs =
38 | HA.attrList
39 | [ HA.class "tag-pill tag-default" ]
40 | [ ( HE.onClick <| onClick tag
41 | , Just tag /= activeTag
42 | )
43 | ]
44 | in
45 | H.button attrs [ H.text <| Tag.toString tag ]
46 | )
47 | tags
48 |
49 | Error message ->
50 | [ H.text message ]
51 | ]
52 |
--------------------------------------------------------------------------------
/src/Api/ToggleFavourite.elm:
--------------------------------------------------------------------------------
1 | module Api.ToggleFavourite exposing (Options, TotalFavourites, toggleFavourite)
2 |
3 | import Api
4 | import Data.Slug as Slug exposing (Slug)
5 | import Data.Token exposing (Token)
6 | import Data.Total as Total exposing (Total)
7 | import Http
8 | import Json.Decode as JD
9 | import Lib.Either as Either
10 | import Url exposing (Url)
11 |
12 |
13 | type alias Options msg =
14 | { token : Token
15 | , slug : Slug
16 | , isFavourite : Bool
17 | , onResponse : Result (Api.Error ()) TotalFavourites -> msg
18 | }
19 |
20 |
21 | toggleFavourite : Url -> Options msg -> Cmd msg
22 | toggleFavourite baseUrl { token, slug, isFavourite, onResponse } =
23 | Api.request
24 | { method =
25 | if isFavourite then
26 | Api.POST
27 |
28 | else
29 | Api.DELETE
30 | , maybeToken = Just token
31 | , url =
32 | Api.buildUrl
33 | baseUrl
34 | [ "articles", Slug.toString slug, "favorite" ]
35 | []
36 | []
37 | , body = Http.emptyBody
38 | , onResponse = onResponse
39 | , eitherDefaultOrDecoder = Either.Right decoder
40 | , errorsDecoder = Api.emptyErrorsDecoder
41 | }
42 |
43 |
44 | type alias TotalFavourites =
45 | { slug : Slug
46 | , isFavourite : Bool
47 | , totalFavourites : Total
48 | }
49 |
50 |
51 | decoder : JD.Decoder TotalFavourites
52 | decoder =
53 | JD.field "article" <|
54 | JD.map3 TotalFavourites
55 | (JD.field "slug" Slug.decoder)
56 | (JD.field "favorited" JD.bool)
57 | (JD.field "favoritesCount" Total.decoder)
58 |
--------------------------------------------------------------------------------
/src/View/FeedTabs.elm:
--------------------------------------------------------------------------------
1 | module View.FeedTabs exposing (Tab(..), ViewOptions, activeTag, view)
2 |
3 | import Data.Tag as Tag exposing (Tag)
4 | import Html as H
5 | import View.Tabs as Tabs
6 |
7 |
8 | type alias ViewOptions msg =
9 | { hasPersonal : Bool
10 | , maybeTag : Maybe Tag
11 | , activeTab : Tab
12 | , isDisabled : Bool
13 | , onSwitch : Tab -> msg
14 | }
15 |
16 |
17 | type Tab
18 | = Personal
19 | | Global
20 | | Tag Tag
21 |
22 |
23 | activeTag : Tab -> Maybe Tag
24 | activeTag tab =
25 | case tab of
26 | Tag tag ->
27 | Just tag
28 |
29 | _ ->
30 | Nothing
31 |
32 |
33 | view : ViewOptions msg -> H.Html msg
34 | view { hasPersonal, maybeTag, activeTab, isDisabled, onSwitch } =
35 | let
36 | tabs =
37 | List.filterMap identity <|
38 | [ if hasPersonal then
39 | Just
40 | { id = Personal
41 | , title = "Your Feed"
42 | }
43 |
44 | else
45 | Nothing
46 | , Just
47 | { id = Global
48 | , title = "Global Feed"
49 | }
50 | , maybeTag
51 | |> Maybe.map
52 | (\tag ->
53 | { id = Tag tag
54 | , title = "#" ++ Tag.toString tag
55 | }
56 | )
57 | ]
58 | in
59 | Tabs.view
60 | { name = "feed"
61 | , tabs = tabs
62 | , activeTab = activeTab
63 | , isDisabled = isDisabled
64 | , onSwitch = onSwitch
65 | }
66 |
--------------------------------------------------------------------------------
/review/src/ReviewConfig.elm:
--------------------------------------------------------------------------------
1 | module ReviewConfig exposing (config)
2 |
3 | {-| Do not rename the ReviewConfig module or the config function, because
4 | `elm-review` will look for these.
5 |
6 | To add packages that contain rules, add them to this review project using
7 |
8 | `elm install author/packagename`
9 |
10 | when inside the directory containing this file.
11 |
12 | -}
13 |
14 | import Docs.ReviewAtDocs
15 | import NoConfusingPrefixOperator
16 | import NoDebug.Log
17 | import NoDebug.TodoOrToString
18 | import NoExposingEverything
19 | import NoImportingEverything
20 | import NoMissingTypeAnnotation
21 | import NoMissingTypeExpose
22 | import NoPrematureLetComputation
23 | import NoSimpleLetBody
24 | import NoUnused.CustomTypeConstructorArgs
25 | import NoUnused.CustomTypeConstructors
26 | import NoUnused.Dependencies
27 | import NoUnused.Exports
28 | import NoUnused.Parameters
29 | import NoUnused.Patterns
30 | import NoUnused.Variables
31 | import Review.Rule as Rule exposing (Rule)
32 | import Simplify
33 |
34 |
35 | config : List Rule
36 | config =
37 | [ Docs.ReviewAtDocs.rule
38 | , NoConfusingPrefixOperator.rule
39 | , NoDebug.Log.rule
40 | , NoDebug.TodoOrToString.rule
41 | |> Rule.ignoreErrorsForDirectories [ "tests/" ]
42 | , NoExposingEverything.rule
43 | , NoImportingEverything.rule []
44 | , NoMissingTypeAnnotation.rule
45 | , NoMissingTypeExpose.rule
46 | , NoSimpleLetBody.rule
47 | , NoPrematureLetComputation.rule
48 | , NoUnused.CustomTypeConstructors.rule []
49 | , NoUnused.CustomTypeConstructorArgs.rule
50 | , NoUnused.Dependencies.rule
51 | , NoUnused.Exports.rule
52 | , NoUnused.Parameters.rule
53 | , NoUnused.Patterns.rule
54 | , NoUnused.Variables.rule
55 | , Simplify.rule Simplify.defaults
56 | ]
57 |
--------------------------------------------------------------------------------
/prototype/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Sign in — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
Sign in
21 |
22 | Need an account?
23 |
24 |
25 |
26 | - That email is already taken.
27 |
28 |
29 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/tests/Test/Data/Password.elm:
--------------------------------------------------------------------------------
1 | module Test.Data.Password exposing (suite)
2 |
3 | import Data.Password as Password
4 | import Expect
5 | import Test exposing (Test, describe, fuzz, test)
6 | import Test.Lib.Fuzz as Fuzz
7 |
8 |
9 | suite : Test
10 | suite =
11 | describe "Data.Password"
12 | [ fromStringSuite
13 | ]
14 |
15 |
16 | fromStringSuite : Test
17 | fromStringSuite =
18 | describe "fromString"
19 | [ fuzz Fuzz.onlyAsciiWhitespace "Err Blank" <|
20 | \ws ->
21 | Password.fromString ws
22 | |> Expect.equal (Err Password.Blank)
23 | , describe "Err TooShort" <|
24 | List.map
25 | (\input ->
26 | let
27 | description =
28 | "password = " ++ input
29 | in
30 | test description <|
31 | \_ ->
32 | Password.fromString input
33 | |> Expect.equal (Err <| Password.TooShort 6)
34 | )
35 | [ "a"
36 | , "ab"
37 | , "abc"
38 | , "abc1"
39 | , "abc12"
40 | ]
41 | , describe "Ok" <|
42 | List.map
43 | (\input ->
44 | let
45 | description =
46 | "password = " ++ input
47 | in
48 | test description <|
49 | \_ ->
50 | Password.fromString input
51 | |> Expect.ok
52 | )
53 | [ "abc123"
54 | , "123456"
55 | , "password"
56 | , "pa5$w0rD!"
57 | ]
58 | ]
59 |
--------------------------------------------------------------------------------
/src/View/RegisterForm.elm:
--------------------------------------------------------------------------------
1 | module View.RegisterForm exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 | import View.Input as Input
7 |
8 |
9 | type alias ViewOptions msg =
10 | { username : String
11 | , email : String
12 | , password : String
13 | , isDisabled : Bool
14 | , onInputUsername : String -> msg
15 | , onInputEmail : String -> msg
16 | , onInputPassword : String -> msg
17 | , onSubmit : msg
18 | }
19 |
20 |
21 | view : ViewOptions msg -> H.Html msg
22 | view { username, email, password, isDisabled, onInputUsername, onInputEmail, onInputPassword, onSubmit } =
23 | let
24 | attrs =
25 | if isDisabled then
26 | []
27 |
28 | else
29 | [ HE.onSubmit onSubmit ]
30 | in
31 | H.form attrs
32 | [ Input.view
33 | { name = "username"
34 | , type_ = "text"
35 | , placeholder = "Username"
36 | , value = username
37 | , isDisabled = isDisabled
38 | , onInput = onInputUsername
39 | }
40 | , Input.view
41 | { name = "email"
42 | , type_ = "text"
43 | , placeholder = "Email"
44 | , value = email
45 | , isDisabled = isDisabled
46 | , onInput = onInputEmail
47 | }
48 | , Input.view
49 | { name = "password"
50 | , type_ = "password"
51 | , placeholder = "Password"
52 | , value = password
53 | , isDisabled = isDisabled
54 | , onInput = onInputPassword
55 | }
56 | , H.button
57 | [ HA.class "btn btn-lg btn-primary pull-xs-right"
58 | , HA.type_ "submit"
59 | , HA.disabled isDisabled
60 | ]
61 | [ H.text "Sign up" ]
62 | ]
63 |
--------------------------------------------------------------------------------
/prototype/register.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Sign up — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
Sign up
21 |
22 | Have an account?
23 |
24 |
25 |
26 | - That email is already taken.
27 |
28 |
29 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/Data/Pager.elm:
--------------------------------------------------------------------------------
1 | module Data.Pager exposing
2 | ( Page
3 | , Pager
4 | , five
5 | , setTotalPages
6 | , ten
7 | , toPage
8 | , toTotalPages
9 | )
10 |
11 | import Data.Limit as Limit exposing (Limit)
12 | import Data.Offset as Offset exposing (Offset)
13 | import Data.PageNumber as PageNumber exposing (PageNumber)
14 | import Data.Total as Total exposing (Total)
15 |
16 |
17 | type Pager
18 | = Pager
19 | { limit : Limit
20 | , maybeTotalPages : Maybe Total
21 | }
22 |
23 |
24 | five : Pager
25 | five =
26 | fromLimit Limit.five
27 |
28 |
29 | ten : Pager
30 | ten =
31 | fromLimit Limit.ten
32 |
33 |
34 | fromLimit : Limit -> Pager
35 | fromLimit limit =
36 | Pager
37 | { limit = limit
38 | , maybeTotalPages = Nothing
39 | }
40 |
41 |
42 | setTotalPages : Total -> Pager -> Pager
43 | setTotalPages totalItems (Pager pager) =
44 | let
45 | totalPages =
46 | Total.fromInt <| total // limit + extra
47 |
48 | total =
49 | Total.toInt totalItems
50 |
51 | limit =
52 | Limit.toInt pager.limit
53 |
54 | extra =
55 | if modBy limit total == 0 then
56 | 0
57 |
58 | else
59 | 1
60 | in
61 | Pager { pager | maybeTotalPages = Just totalPages }
62 |
63 |
64 | toTotalPages : Pager -> Total
65 | toTotalPages (Pager { maybeTotalPages }) =
66 | Maybe.withDefault Total.zero maybeTotalPages
67 |
68 |
69 | type alias Page =
70 | { offset : Offset
71 | , limit : Limit
72 | }
73 |
74 |
75 | toPage : PageNumber -> Pager -> Page
76 | toPage pageNumber (Pager { limit, maybeTotalPages }) =
77 | case maybeTotalPages of
78 | Nothing ->
79 | { offset = Offset.zero
80 | , limit = limit
81 | }
82 |
83 | Just _ ->
84 | { offset = Offset.fromInt <| (PageNumber.toInt pageNumber - 1) * Limit.toInt limit
85 | , limit = limit
86 | }
87 |
--------------------------------------------------------------------------------
/src/View/CommentForm.elm:
--------------------------------------------------------------------------------
1 | module View.CommentForm exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 | import Url exposing (Url)
7 |
8 |
9 | type alias ViewOptions msg =
10 | { htmlId : String
11 | , comment : String
12 | , imageUrl : Url
13 | , isDisabled : Bool
14 | , onInputComment : String -> msg
15 | , onSubmit : msg
16 | }
17 |
18 |
19 | view : ViewOptions msg -> H.Html msg
20 | view { htmlId, comment, imageUrl, isDisabled, onInputComment, onSubmit } =
21 | let
22 | isButtonDisabled =
23 | isEmptyComment || isDisabled
24 |
25 | isEmptyComment =
26 | String.isEmpty <| String.trim comment
27 |
28 | baseAttrs =
29 | [ HA.class "card comment-form" ]
30 |
31 | optionalAttrs =
32 | if isDisabled then
33 | []
34 |
35 | else
36 | [ HE.onSubmit onSubmit ]
37 |
38 | attrs =
39 | baseAttrs ++ optionalAttrs
40 | in
41 | H.form attrs
42 | [ H.div
43 | [ HA.class "card-block" ]
44 | [ H.textarea
45 | [ HA.id htmlId
46 | , HA.class "form-control"
47 | , HA.placeholder "Write a comment..."
48 | , HA.rows 3
49 | , HA.value comment
50 | , if isDisabled then
51 | HA.disabled True
52 |
53 | else
54 | HE.onInput onInputComment
55 | ]
56 | []
57 | ]
58 | , H.div
59 | [ HA.class "card-footer" ]
60 | [ H.img
61 | [ HA.class "comment-author-img"
62 | , HA.src <| Url.toString imageUrl
63 | ]
64 | []
65 | , H.button
66 | [ HA.class "btn btn-sm btn-primary"
67 | , HA.type_ "submit"
68 | , HA.disabled isButtonDisabled
69 | ]
70 | [ H.text "Post Comment" ]
71 | ]
72 | ]
73 |
--------------------------------------------------------------------------------
/src/View/FollowButton.elm:
--------------------------------------------------------------------------------
1 | module View.FollowButton exposing (ViewOptions, view)
2 |
3 | import Data.Total as Total exposing (Total)
4 | import Data.Username as Username exposing (Username)
5 | import Html as H
6 | import Html.Attributes as HA
7 | import Html.Events as HE
8 |
9 |
10 | type alias ViewOptions msg =
11 | { username : Username
12 | , isFollowing : Bool
13 | , maybeTotalFollowers : Maybe Total
14 | , isDisabled : Bool
15 | , onFollow : msg
16 | , onUnfollow : msg
17 | }
18 |
19 |
20 | view : ViewOptions msg -> H.Html msg
21 | view { username, isFollowing, maybeTotalFollowers, isDisabled, onFollow, onUnfollow } =
22 | let
23 | { action, buttonClass, iconClass, onClick } =
24 | if isFollowing then
25 | { action = "Unfollow"
26 | , buttonClass = "btn-secondary"
27 | , iconClass = "ion-minus-round"
28 | , onClick = onUnfollow
29 | }
30 |
31 | else
32 | { action = "Follow"
33 | , buttonClass = "btn-outline-secondary"
34 | , iconClass = "ion-plus-round"
35 | , onClick = onFollow
36 | }
37 |
38 | requiredChildren =
39 | [ H.i
40 | [ HA.class iconClass ]
41 | []
42 | , H.text <| "\u{00A0} " ++ action ++ " " ++ Username.toString username
43 | ]
44 |
45 | optionalChildren =
46 | case maybeTotalFollowers of
47 | Nothing ->
48 | []
49 |
50 | Just totalFollowers ->
51 | [ H.text " "
52 | , H.span
53 | [ HA.class "counter" ]
54 | [ H.text <| "(" ++ Total.toString totalFollowers ++ ")" ]
55 | ]
56 | in
57 | H.button
58 | [ HA.class "btn btn-sm action-btn"
59 | , HA.class buttonClass
60 | , if isDisabled then
61 | HA.disabled True
62 |
63 | else
64 | HE.onClick onClick
65 | ]
66 | (requiredChildren ++ optionalChildren)
67 |
--------------------------------------------------------------------------------
/src/View/Pagination.elm:
--------------------------------------------------------------------------------
1 | module View.Pagination exposing (ViewOptions, view)
2 |
3 | import Data.PageNumber as PageNumber exposing (PageNumber)
4 | import Data.Total as Total exposing (Total)
5 | import Html as H
6 | import Html.Attributes as HA
7 | import Html.Events as HE
8 | import Lib.Html.Attributes as HA
9 |
10 |
11 | type alias ViewOptions msg =
12 | { totalPages : Total
13 | , currentPageNumber : PageNumber
14 | , onChangePageNumber : PageNumber -> msg
15 | }
16 |
17 |
18 | view : ViewOptions msg -> H.Html msg
19 | view options =
20 | let
21 | n =
22 | Total.toInt options.totalPages
23 | in
24 | if n <= 1 then
25 | H.text ""
26 |
27 | else
28 | let
29 | viewPageItems =
30 | n
31 | |> List.range 1
32 | |> List.map
33 | (\i ->
34 | viewPageItem
35 | { pageNumber = PageNumber.fromInt i
36 | , currentPageNumber = options.currentPageNumber
37 | , onChangePageNumber = options.onChangePageNumber
38 | }
39 | )
40 | in
41 | H.ul [ HA.class "pagination" ] viewPageItems
42 |
43 |
44 | type alias PageItemOptions msg =
45 | { pageNumber : PageNumber
46 | , currentPageNumber : PageNumber
47 | , onChangePageNumber : PageNumber -> msg
48 | }
49 |
50 |
51 | viewPageItem : PageItemOptions msg -> H.Html msg
52 | viewPageItem { pageNumber, currentPageNumber, onChangePageNumber } =
53 | let
54 | isActive =
55 | pageNumber == currentPageNumber
56 |
57 | buttonAttrs =
58 | HA.attrList
59 | [ HA.class "page-link" ]
60 | [ ( HE.onClick <| onChangePageNumber pageNumber
61 | , not isActive
62 | )
63 | ]
64 | in
65 | H.li
66 | [ HA.class "page-item"
67 | , HA.classList [ ( "active", isActive ) ]
68 | ]
69 | [ H.button buttonAttrs
70 | [ H.text <| PageNumber.toString pageNumber ]
71 | ]
72 |
--------------------------------------------------------------------------------
/tests/Test/Data/Timestamp.elm:
--------------------------------------------------------------------------------
1 | module Test.Data.Timestamp exposing (suite)
2 |
3 | import Data.Timestamp as Timestamp
4 | import Expect
5 | import Test exposing (Test, describe, test)
6 | import Time
7 |
8 |
9 | suite : Test
10 | suite =
11 | describe "Data.Timestamp"
12 | [ toDayStringSuite
13 | , toTimeStringSuite
14 | ]
15 |
16 |
17 | toDayStringSuite : Test
18 | toDayStringSuite =
19 | describe "toDayString" <|
20 | List.map
21 | (\( input, dayString ) ->
22 | test input <|
23 | \_ ->
24 | input
25 | |> Timestamp.fromString
26 | |> Maybe.map (Timestamp.toDayString Time.utc)
27 | |> Expect.equal (Just dayString)
28 | )
29 | [ ( "2024-01-01T00:00:00.000Z", "January 1st, 2024" )
30 | , ( "2024-01-01T23:59:59.999Z", "January 1st, 2024" )
31 | , ( "2024-02-02T00:00:00.000Z", "February 2nd, 2024" )
32 | , ( "2024-03-03T00:00:00.000Z", "March 3rd, 2024" )
33 | , ( "2024-04-04T00:00:00.000Z", "April 4th, 2024" )
34 | , ( "2024-05-11T00:00:00.000Z", "May 11th, 2024" )
35 | , ( "2024-06-21T00:00:00.000Z", "June 21st, 2024" )
36 | , ( "2024-07-31T00:00:00.000Z", "July 31st, 2024" )
37 | ]
38 |
39 |
40 | toTimeStringSuite : Test
41 | toTimeStringSuite =
42 | describe "toTimeString" <|
43 | List.map
44 | (\( input, timeString ) ->
45 | test input <|
46 | \_ ->
47 | input
48 | |> Timestamp.fromString
49 | |> Maybe.map (Timestamp.toTimeString Time.utc)
50 | |> Expect.equal (Just timeString)
51 | )
52 | [ ( "2024-01-01T00:00:00.000Z", "12:00 am" )
53 | , ( "2024-01-01T00:01:00.000Z", "12:01 am" )
54 | , ( "2024-01-01T05:10:00.000Z", "5:10 am" )
55 | , ( "2024-01-01T12:00:00.000Z", "12:00 pm" )
56 | , ( "2024-01-01T13:02:00.000Z", "1:02 pm" )
57 | , ( "2024-01-01T23:59:59.999Z", "11:59 pm" )
58 | ]
59 |
--------------------------------------------------------------------------------
/bin/deploy-production:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: [simulate=0*|1] deploy-production
5 | #
6 |
7 | set -euo pipefail
8 |
9 | simulate="${simulate:-0}"
10 |
11 | if [[ "$simulate" != 0 ]]; then
12 | echo "ATTENTION!!! This is only a simulation."
13 | set -x
14 | fi
15 |
16 |
17 | # CHECK BRANCH
18 |
19 |
20 | current_branch="$(git branch --show-current)"
21 | if [[ "$current_branch" != master ]]; then
22 | echo "You are currently on the branch: $current_branch"
23 | #
24 | # NOTE:
25 | #
26 | # Usually you'd want to deploy from the master branch. On rare
27 | # occassions you'd deploy from another branch. So deploying
28 | # from a different branch could be a mistake and that's why we
29 | # verify if you want to continue with the deploy.
30 | #
31 | read -r -n 1 -t 30 -p "Are you sure want to continue? (y/N) "
32 | case $REPLY in
33 | y | Y )
34 | echo
35 | ;;
36 |
37 | * )
38 | exit 1
39 | esac
40 | fi
41 |
42 |
43 | # BUILD
44 |
45 |
46 | build-production
47 |
48 |
49 | # PREPARE DEPLOY DIRECTORY
50 |
51 |
52 | out="$(mktemp -d -t deploy-XXXXX)"
53 | echo "Prepared the deploy directory: $out"
54 |
55 |
56 | # PREPARE WORKTREE
57 |
58 |
59 | branch_name="production"
60 |
61 | if [[ "$simulate" = 0 ]]; then
62 | git worktree add "$out" "$branch_name"
63 | git -C "$out" pull
64 | fi
65 |
66 |
67 | # DEPLOY
68 |
69 |
70 | src="${build:?}/application"
71 | hash="$(git log -n 1 --format='%h' "$current_branch")"
72 | message="Site updated to commit $hash from the $current_branch branch"
73 |
74 | cp -r "$src/"* "$out"
75 | cp -r "${project:?}/config/production/"* "$out"
76 |
77 | if [[ "$simulate" != 0 ]]; then
78 | echo "$message"
79 | else
80 | git -C "$out" add -N .
81 |
82 | if git -C "$out" diff --quiet; then
83 | echo "No changes detected."
84 | else
85 | git -C "$out" add .
86 | git -C "$out" commit -m "$message"
87 | git -C "$out" push -u origin HEAD
88 | fi
89 | fi
90 |
91 |
92 | # CLEAN UP
93 |
94 |
95 | if [[ "$simulate" != 0 ]]; then
96 | echo "Please run \"rm -rf $out\" when you're done"
97 | else
98 | git worktree remove --force "$out"
99 | rm -rf "$out"
100 |
101 | echo "Success!"
102 | fi
103 |
--------------------------------------------------------------------------------
/src/Data/Timestamp.elm:
--------------------------------------------------------------------------------
1 | module Data.Timestamp exposing
2 | ( Timestamp
3 | , compare
4 | , decoder
5 | , fromString
6 | , toDayAndTimeString
7 | , toDayString
8 | , toTimeString
9 | )
10 |
11 | import Date
12 | import Iso8601
13 | import Json.Decode as JD
14 | import Time
15 |
16 |
17 | type Timestamp
18 | = Timestamp Time.Posix
19 |
20 |
21 | fromString : String -> Maybe Timestamp
22 | fromString =
23 | Iso8601.toTime >> Result.toMaybe >> Maybe.map Timestamp
24 |
25 |
26 | decoder : JD.Decoder Timestamp
27 | decoder =
28 | JD.map Timestamp Iso8601.decoder
29 |
30 |
31 | compare : Timestamp -> Timestamp -> Order
32 | compare (Timestamp posix1) (Timestamp posix2) =
33 | let
34 | millis1 =
35 | Time.posixToMillis posix1
36 |
37 | millis2 =
38 | Time.posixToMillis posix2
39 | in
40 | Basics.compare millis1 millis2
41 |
42 |
43 | toDayAndTimeString : Time.Zone -> Timestamp -> String
44 | toDayAndTimeString zone timestamp =
45 | toDayString zone timestamp ++ " at " ++ toTimeString zone timestamp
46 |
47 |
48 | toDayString : Time.Zone -> Timestamp -> String
49 | toDayString zone (Timestamp posix) =
50 | posix
51 | |> Date.fromPosix zone
52 | |> Date.format "MMMM ddd, y"
53 |
54 |
55 | toTimeString : Time.Zone -> Timestamp -> String
56 | toTimeString zone (Timestamp posix) =
57 | let
58 | hour24 =
59 | Time.toHour zone posix
60 |
61 | --
62 | -- Based on https://www.timeanddate.com/time/am-and-pm.html.
63 | --
64 | ( hour12, period ) =
65 | if hour24 < 12 then
66 | ( if hour24 == 0 then
67 | 12
68 |
69 | else
70 | hour24
71 | , "am"
72 | )
73 |
74 | else
75 | ( if hour24 == 12 then
76 | 12
77 |
78 | else
79 | hour24 - 12
80 | , "pm"
81 | )
82 |
83 | minute =
84 | Time.toMinute zone posix
85 | in
86 | String.concat
87 | [ String.fromInt hour12
88 | , ":"
89 | , String.fromInt minute
90 | |> String.padLeft 2 '0'
91 | , " "
92 | , period
93 | ]
94 |
--------------------------------------------------------------------------------
/src/Api/UpdateUser.elm:
--------------------------------------------------------------------------------
1 | module Api.UpdateUser exposing (Options, updateUser)
2 |
3 | import Api
4 | import Data.Email as Email exposing (Email)
5 | import Data.Password as Password exposing (Password)
6 | import Data.Token exposing (Token)
7 | import Data.User as User exposing (User)
8 | import Data.Username as Username exposing (Username)
9 | import Http
10 | import Json.Decode as JD
11 | import Json.Encode as JE
12 | import Lib.Json.Encode as JE
13 | import Url exposing (Url)
14 |
15 |
16 | type alias Options msg =
17 | { token : Token
18 | , imageUrl : Url
19 | , username : Username
20 | , bio : String
21 | , email : Email
22 | , maybePassword : Maybe Password
23 | , onResponse : Result (Api.Error (List String)) User -> msg
24 | }
25 |
26 |
27 | updateUser : Url -> Options msg -> Cmd msg
28 | updateUser baseUrl { token, imageUrl, username, bio, email, maybePassword, onResponse } =
29 | Api.put
30 | { token = token
31 | , url = Api.buildUrl baseUrl [ "user" ] [] []
32 | , body =
33 | Http.jsonBody <|
34 | encodeInput imageUrl username bio email maybePassword
35 | , onResponse = onResponse
36 | , decoder = decoder
37 | , errorsDecoder =
38 | Api.formErrorsDecoder
39 | [ "image"
40 | , "username"
41 | , "bio"
42 | , "email"
43 | , "password"
44 | ]
45 | }
46 |
47 |
48 | encodeInput : Url -> Username -> String -> Email -> Maybe Password -> JE.Value
49 | encodeInput imageUrl username bio email maybePassword =
50 | JE.object
51 | [ ( "user"
52 | , JE.object
53 | ([ ( "image", JE.url imageUrl )
54 | , ( "username", Username.encode username )
55 | , ( "bio", JE.string bio )
56 | , ( "email", Email.encode email )
57 | ]
58 | ++ (case maybePassword of
59 | Just password ->
60 | [ ( "password", Password.encode password ) ]
61 |
62 | Nothing ->
63 | []
64 | )
65 | )
66 | )
67 | ]
68 |
69 |
70 | decoder : JD.Decoder User
71 | decoder =
72 | JD.field "user" User.decoder
73 |
--------------------------------------------------------------------------------
/tests/Test/Data/Tag.elm:
--------------------------------------------------------------------------------
1 | module Test.Data.Tag exposing (suite)
2 |
3 | import Data.Tag as Tag exposing (Tag)
4 | import Expect
5 | import Fuzz exposing (Fuzzer)
6 | import Json.Decode as JD
7 | import Json.Encode as JE
8 | import Test exposing (Test, describe, fuzz, test)
9 | import Test.Lib.Fuzz as Fuzz
10 |
11 |
12 | suite : Test
13 | suite =
14 | describe "Data.Tag"
15 | [ fromStringSuite
16 | , decoderSuite
17 | ]
18 |
19 |
20 | fromStringSuite : Test
21 | fromStringSuite =
22 | describe "fromString"
23 | [ fuzz Fuzz.onlyAsciiWhitespace "no empty tags" <|
24 | \ws ->
25 | Tag.fromString ws
26 | |> Expect.equal Nothing
27 | , describe "non-empty tags" <|
28 | List.map
29 | (\input ->
30 | test input <|
31 | \_ ->
32 | Tag.fromString input
33 | |> Maybe.map Tag.toString
34 | |> Expect.equal (Just input)
35 | )
36 | [ "Agda"
37 | , "Basic"
38 | , "Crystal"
39 | , "D"
40 | , "Elm"
41 | ]
42 | , test "it removes leading and trailing whitespace" <|
43 | \_ ->
44 | Tag.fromString " Elm "
45 | |> Maybe.map Tag.toString
46 | |> Expect.equal (Just "Elm")
47 | ]
48 |
49 |
50 | decoderSuite : Test
51 | decoderSuite =
52 | describe "decoder"
53 | [ fuzz tagFuzzer "works with any string" <|
54 | \( input, value ) ->
55 | case JD.decodeValue tagDecoder value of
56 | Ok tag ->
57 | Tag.toString tag
58 | |> Expect.equal input
59 |
60 | Err error ->
61 | Expect.fail <| JD.errorToString error
62 | ]
63 |
64 |
65 | tagFuzzer : Fuzzer ( String, JE.Value )
66 | tagFuzzer =
67 | Fuzz.string
68 | |> Fuzz.map
69 | (\tag ->
70 | ( tag
71 | , JE.object
72 | [ ( "tag", JE.string tag )
73 | ]
74 | )
75 | )
76 |
77 |
78 | tagDecoder : JD.Decoder Tag
79 | tagDecoder =
80 | JD.field "tag" Tag.decoder
81 |
--------------------------------------------------------------------------------
/prototype/editor.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | New Article — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | - That title is required.
23 |
24 |
25 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 
2 |
3 | [][RealWorld]
4 | [](https://app.netlify.com/sites/elm-conduit/deploys?branch=production)
5 |
6 |
7 | > [Elm](https://elm-lang.org) codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld][RealWorld] spec and API.
8 |
9 | ### [Demo](https://elm-conduit.netlify.app) [RealWorld][RealWorld]
10 |
11 | This codebase was created to demonstrate a fully fledged fullstack application built with [Elm](http://elm-lang.org) including CRUD operations, authentication, routing, pagination, and more.
12 |
13 | For more information on how this works with other frontends/backends, head over to the [RealWorld][RealWorld] repo.
14 |
15 | ## Tour
16 |
17 | You can read "[Yet Another Tour of an Open-Source Elm SPA](https://dev.to/dwayne/yet-another-tour-of-an-open-source-elm-spa-1672)" to get a full tour of the application.
18 |
19 | ## Develop
20 |
21 | An isolated, reproducible development environment is provided with [Devbox](https://www.jetify.com/devbox).
22 |
23 | You can enter its development environment as follows:
24 |
25 | ```bash
26 | $ devbox shell
27 | ```
28 |
29 | **N.B.** *To run the Bash scripts mentioned below you will need to enter the development environment.*
30 |
31 | ## Build
32 |
33 | To build the prototype:
34 |
35 | ```bash
36 | $ build-prototype
37 | ```
38 |
39 | To build the sandbox:
40 |
41 | ```bash
42 | $ build-sandbox
43 | ```
44 |
45 | To build the development version of the application:
46 |
47 | ```bash
48 | $ build
49 | ```
50 |
51 | To build the production version of the application:
52 |
53 | ```bash
54 | $ build-production
55 | ```
56 |
57 | ## Serve
58 |
59 | To serve the prototype:
60 |
61 | ```bash
62 | $ serve-prototype
63 | ```
64 |
65 | To serve the sandbox:
66 |
67 | ```bash
68 | $ serve-sandbox
69 | ```
70 |
71 | To serve the development or production version of the application:
72 |
73 | ```bash
74 | $ serve
75 | ```
76 |
77 | ## Deploy
78 |
79 | To deploy the production version of the application to [Netlify](https://www.netlify.com/):
80 |
81 | ```bash
82 | $ deploy-production
83 | ```
84 |
85 | [RealWorld]: https://github.com/gothinkster/realworld
86 |
--------------------------------------------------------------------------------
/src/Data/Article.elm:
--------------------------------------------------------------------------------
1 | module Data.Article exposing (Article, Author, Fields, decoder, encode, fieldNames)
2 |
3 | import Data.Slug as Slug exposing (Slug)
4 | import Data.Tag as Tag exposing (Tag)
5 | import Data.Timestamp as Timestamp exposing (Timestamp)
6 | import Data.Total as Total exposing (Total)
7 | import Data.Username as Username exposing (Username)
8 | import Json.Decode as JD
9 | import Json.Decode.Pipeline as JD
10 | import Json.Encode as JE
11 | import Lib.Json.Decode as JD
12 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
13 | import Url exposing (Url)
14 |
15 |
16 | type alias Article =
17 | { slug : Slug
18 | , title : String
19 | , description : String
20 | , body : String
21 | , tags : List Tag
22 | , createdAt : Timestamp
23 | , isFavourite : Bool
24 | , totalFavourites : Total
25 | , author : Author
26 | }
27 |
28 |
29 | type alias Author =
30 | { username : Username
31 | , imageUrl : Url
32 | , isFollowing : Bool
33 | }
34 |
35 |
36 | type alias Fields =
37 | { title : NonEmptyString
38 | , description : NonEmptyString
39 | , body : NonEmptyString
40 | , tags : List Tag
41 | }
42 |
43 |
44 | encode : Fields -> JE.Value
45 | encode { title, description, body, tags } =
46 | JE.object
47 | [ ( "title", NonEmptyString.encode title )
48 | , ( "description", NonEmptyString.encode description )
49 | , ( "body", NonEmptyString.encode body )
50 | , ( "tagList", JE.list Tag.encode tags )
51 | ]
52 |
53 |
54 | fieldNames : List String
55 | fieldNames =
56 | [ "title"
57 | , "description"
58 | , "body"
59 | , "tagList"
60 | ]
61 |
62 |
63 | decoder : JD.Decoder Article
64 | decoder =
65 | JD.succeed Article
66 | |> JD.required "slug" Slug.decoder
67 | |> JD.required "title" JD.string
68 | |> JD.required "description" JD.string
69 | |> JD.required "body" JD.string
70 | |> JD.required "tagList" (JD.list Tag.decoder)
71 | |> JD.required "createdAt" Timestamp.decoder
72 | |> JD.required "favorited" JD.bool
73 | |> JD.required "favoritesCount" Total.decoder
74 | |> JD.required "author" authorDecoder
75 |
76 |
77 | authorDecoder : JD.Decoder Author
78 | authorDecoder =
79 | JD.map3 Author
80 | (JD.field "username" Username.decoder)
81 | (JD.field "image" JD.url)
82 | (JD.field "following" JD.bool)
83 |
--------------------------------------------------------------------------------
/prototype/settings.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Settings — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/View/ProfileHeader.elm:
--------------------------------------------------------------------------------
1 | module View.ProfileHeader exposing (Role(..), UserOptions, ViewOptions, view)
2 |
3 | import Data.Route as Route
4 | import Data.Username as Username exposing (Username)
5 | import Html as H
6 | import Html.Attributes as HA
7 | import Url exposing (Url)
8 | import View.FollowButton as FollowButton
9 |
10 |
11 | type alias ViewOptions msg =
12 | { username : Username
13 | , imageUrl : Url
14 | , bio : String
15 | , role : Role msg
16 | }
17 |
18 |
19 | type Role msg
20 | = Guest
21 | | User (UserOptions msg)
22 | | Owner
23 |
24 |
25 | type alias UserOptions msg =
26 | { isFollowing : Bool
27 | , isDisabled : Bool
28 | , onFollow : msg
29 | , onUnfollow : msg
30 | }
31 |
32 |
33 | view : ViewOptions msg -> H.Html msg
34 | view { username, imageUrl, bio, role } =
35 | H.div
36 | [ HA.class "user-info" ]
37 | [ H.div
38 | [ HA.class "container" ]
39 | [ H.div
40 | [ HA.class "row" ]
41 | [ H.div
42 | [ HA.class "col-xs-12 col-md-10 offset-md-1" ]
43 | [ H.img
44 | [ HA.class "user-img"
45 | , HA.src <| Url.toString imageUrl
46 | ]
47 | []
48 | , H.h4 [] [ H.text <| Username.toString username ]
49 | , H.p [] [ H.text bio ]
50 | , case role of
51 | Guest ->
52 | H.text ""
53 |
54 | User { isFollowing, isDisabled, onFollow, onUnfollow } ->
55 | FollowButton.view
56 | { username = username
57 | , isFollowing = isFollowing
58 | , maybeTotalFollowers = Nothing
59 | , isDisabled = isDisabled
60 | , onFollow = onFollow
61 | , onUnfollow = onUnfollow
62 | }
63 |
64 | Owner ->
65 | H.a
66 | [ HA.class "btn btn-sm btn-outline-secondary action-btn"
67 | , HA.href <| Route.toString Route.Settings
68 | ]
69 | [ H.i
70 | [ HA.class "ion-gear-a" ]
71 | []
72 | , H.text "\u{00A0} Edit Profile Settings"
73 | ]
74 | ]
75 | ]
76 | ]
77 | ]
78 |
--------------------------------------------------------------------------------
/tests/Test/Data/Route.elm:
--------------------------------------------------------------------------------
1 | module Test.Data.Route exposing (suite)
2 |
3 | import Data.Route as Route
4 | import Data.Slug as Slug
5 | import Data.Username as Username
6 | import Expect
7 | import Test exposing (Test, describe, test)
8 | import Url
9 |
10 |
11 | suite : Test
12 | suite =
13 | describe "Data.Route"
14 | [ fromUrlSuite
15 | ]
16 |
17 |
18 | fromUrlSuite : Test
19 | fromUrlSuite =
20 | let
21 | examples =
22 | --
23 | -- [ ( path, maybeRoute ), ... ]
24 | --
25 | [ ( "/", Just Route.Home )
26 | , ( "/login", Just Route.Login )
27 | , ( "/register", Just Route.Register )
28 | , ( "/settings", Just Route.Settings )
29 | , ( "/editor", Just Route.CreateArticle )
30 | , ( "/editor/article-1"
31 | , Maybe.map Route.EditArticle (Slug.fromString "article-1")
32 | )
33 | , ( "/article/article-2"
34 | , Maybe.map Route.Article (Slug.fromString "article-2")
35 | )
36 | , ( "/article/my article"
37 | , Maybe.map Route.Article (Slug.fromString "my article")
38 | )
39 | , ( "/article/my%20article"
40 | , Maybe.map Route.Article (Slug.fromString "my article")
41 | )
42 | , ( "/profile/eric-simons"
43 | , Maybe.map Route.Profile (Username.fromString "eric-simons")
44 | )
45 | , ( "/profile/Eric Simons"
46 | , Maybe.map Route.Profile (Username.fromString "Eric Simons")
47 | )
48 | , ( "/profile/Eric%20Simons"
49 | , Maybe.map Route.Profile (Username.fromString "Eric Simons")
50 | )
51 | , ( "/profile/eric-simons/favourites"
52 | , Maybe.map Route.Favourites (Username.fromString "eric-simons")
53 | )
54 | ]
55 | in
56 | describe "fromUrl" <|
57 | List.map
58 | (\( path, maybeRoute ) ->
59 | test path <|
60 | \_ ->
61 | let
62 | urlString =
63 | "https://elm-conduit.dev" ++ path
64 |
65 | maybeUrl =
66 | Url.fromString urlString
67 | in
68 | case maybeUrl of
69 | Just url ->
70 | Route.fromUrl url
71 | |> Expect.equal maybeRoute
72 |
73 | Nothing ->
74 | Expect.fail <| "Bad URL: " ++ urlString
75 | )
76 | examples
77 |
--------------------------------------------------------------------------------
/src/View/EditorForm.elm:
--------------------------------------------------------------------------------
1 | module View.EditorForm exposing (ViewOptions, view)
2 |
3 | import Data.Tag exposing (Tag)
4 | import Html as H
5 | import Html.Attributes as HA
6 | import Html.Events as HE
7 | import Lib.OrderedSet exposing (OrderedSet)
8 | import View.Input as Input
9 | import View.TagInput as TagInput
10 | import View.Textarea as Textarea
11 |
12 |
13 | type alias ViewOptions msg =
14 | { title : String
15 | , description : String
16 | , body : String
17 | , tag : String
18 | , tags : OrderedSet Tag
19 | , isDisabled : Bool
20 | , onInputTitle : String -> msg
21 | , onInputDescription : String -> msg
22 | , onInputBody : String -> msg
23 | , onInputTag : String -> msg
24 | , onEnterTag : Tag -> msg
25 | , onRemoveTag : Tag -> msg
26 | , onSubmit : msg
27 | }
28 |
29 |
30 | view : ViewOptions msg -> H.Html msg
31 | view { title, description, body, tag, tags, isDisabled, onInputTitle, onInputDescription, onInputBody, onInputTag, onEnterTag, onRemoveTag, onSubmit } =
32 | let
33 | attrs =
34 | if isDisabled then
35 | []
36 |
37 | else
38 | [ HE.onSubmit onSubmit ]
39 | in
40 | H.form attrs
41 | [ H.fieldset []
42 | [ Input.view
43 | { name = "title"
44 | , type_ = "text"
45 | , placeholder = "Article Title"
46 | , value = title
47 | , isDisabled = isDisabled
48 | , onInput = onInputTitle
49 | }
50 | , Input.view
51 | { name = "description"
52 | , type_ = "text"
53 | , placeholder = "What's this article about?"
54 | , value = description
55 | , isDisabled = isDisabled
56 | , onInput = onInputDescription
57 | }
58 | , Textarea.view
59 | { name = "body"
60 | , placeholder = "Write your article (in markdown)"
61 | , rows = 8
62 | , value = body
63 | , isDisabled = isDisabled
64 | , onInput = onInputBody
65 | }
66 | , TagInput.view
67 | { name = "tag"
68 | , placeholder = "Enter tags"
69 | , tag = tag
70 | , tags = tags
71 | , isDisabled = isDisabled
72 | , onInput = onInputTag
73 | , onEnter = onEnterTag
74 | , onRemove = onRemoveTag
75 | }
76 | , H.button
77 | [ HA.class "btn btn-lg btn-primary pull-xs-right"
78 | , HA.type_ "submit"
79 | , HA.disabled isDisabled
80 | ]
81 | [ H.text "Publish Article" ]
82 | ]
83 | ]
84 |
--------------------------------------------------------------------------------
/src/View/SettingsForm.elm:
--------------------------------------------------------------------------------
1 | module View.SettingsForm exposing (ViewOptions, view)
2 |
3 | import Html as H
4 | import Html.Attributes as HA
5 | import Html.Events as HE
6 | import View.Input as Input
7 | import View.Textarea as Textarea
8 |
9 |
10 | type alias ViewOptions msg =
11 | { imageUrl : String
12 | , username : String
13 | , bio : String
14 | , email : String
15 | , password : String
16 | , isDisabled : Bool
17 | , onInputImageUrl : String -> msg
18 | , onInputUsername : String -> msg
19 | , onInputBio : String -> msg
20 | , onInputEmail : String -> msg
21 | , onInputPassword : String -> msg
22 | , onSubmit : msg
23 | }
24 |
25 |
26 | view : ViewOptions msg -> H.Html msg
27 | view { imageUrl, username, bio, email, password, isDisabled, onInputImageUrl, onInputUsername, onInputBio, onInputEmail, onInputPassword, onSubmit } =
28 | let
29 | attrs =
30 | if isDisabled then
31 | []
32 |
33 | else
34 | [ HE.onSubmit onSubmit ]
35 | in
36 | H.form attrs
37 | [ H.fieldset []
38 | [ Input.view
39 | { name = "imageUrl"
40 | , type_ = "text"
41 | , placeholder = "URL of profile picture"
42 | , value = imageUrl
43 | , isDisabled = isDisabled
44 | , onInput = onInputImageUrl
45 | }
46 | , Input.view
47 | { name = "username"
48 | , type_ = "text"
49 | , placeholder = "Your Name"
50 | , value = username
51 | , isDisabled = isDisabled
52 | , onInput = onInputUsername
53 | }
54 | , Textarea.view
55 | { name = "bio"
56 | , placeholder = "Short bio about you"
57 | , rows = 8
58 | , value = bio
59 | , isDisabled = isDisabled
60 | , onInput = onInputBio
61 | }
62 | , Input.view
63 | { name = "email"
64 | , type_ = "text"
65 | , placeholder = "Email"
66 | , value = email
67 | , isDisabled = isDisabled
68 | , onInput = onInputEmail
69 | }
70 | , Input.view
71 | { name = "password"
72 | , type_ = "password"
73 | , placeholder = "New Password"
74 | , value = password
75 | , isDisabled = isDisabled
76 | , onInput = onInputPassword
77 | }
78 | , H.button
79 | [ HA.class "btn btn-lg btn-primary pull-xs-right"
80 | , HA.type_ "submit"
81 | , HA.disabled isDisabled
82 | ]
83 | [ H.text "Update Settings" ]
84 | ]
85 | ]
86 |
--------------------------------------------------------------------------------
/bin/build:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | #
4 | # Usage: [optimize_html=0*|1] [optimize_css=0*|1] [optimize_js=0*|1|2|3] [compress=0*|1] build
5 | #
6 |
7 | set -euo pipefail
8 |
9 | optimize_html="${optimize_html:-0}"
10 | optimize_css="${optimize_css:-0}"
11 | optimize_js="${optimize_js:-0}"
12 | compress="${compress:-0}"
13 |
14 | src="${project:?}"
15 | out="${build:?}/application"
16 |
17 | clean () {
18 | rm -rf "$out"
19 | }
20 |
21 | prepare () {
22 | mkdir -p "$out"
23 | }
24 |
25 | build_images () {
26 | cp -r "$src/images" "$out/images"
27 | }
28 |
29 | build_html () {
30 | if [[ "$optimize_html" = 0 ]]; then
31 | cp "$src/html/"*.html "$out"
32 | else
33 | html-minifier \
34 | --collapse-boolean-attributes \
35 | --collapse-inline-tag-whitespace \
36 | --collapse-whitespace \
37 | --decode-entities \
38 | --minify-js \
39 | --remove-comments \
40 | --remove-empty-attributes \
41 | --remove-redundant-attributes \
42 | --remove-script-type-attributes \
43 | --remove-style-link-type-attributes \
44 | --remove-tag-whitespace \
45 | --file-ext html \
46 | --input-dir "$src/html" \
47 | --output-dir "$out"
48 | fi
49 | }
50 |
51 | build_css () {
52 | if [[ "$optimize_css" = 0 ]]; then
53 | sass --embed-sources "$src/sass/index.scss" "$out/index.css"
54 | else
55 | sass --style=compressed --no-source-map "$src/sass/index.scss" "$out/index.css"
56 | fi
57 | }
58 |
59 | build_js () {
60 | case "${optimize_js}" in
61 | 1|2|3)
62 | func=build_js_optimize_"$optimize_js"
63 | ;;
64 | *)
65 | func=build_js_debug
66 | ;;
67 | esac
68 |
69 | "$func" "$src/src/Main.elm" "$out/app.js"
70 | }
71 |
72 | build_js_debug () {
73 | elm make "$1" --debug --output "$2"
74 | }
75 |
76 | build_js_optimize_1 () {
77 | elm make "$1" --optimize --output "$2"
78 | minify "$2"
79 | }
80 |
81 | build_js_optimize_2 () {
82 | elm-optimize-level-2 "$1" --output "$2"
83 | minify "$2"
84 | }
85 |
86 | build_js_optimize_3 () {
87 | elm-optimize-level-2 "$1" --optimize-speed --output "$2"
88 | minify "$2"
89 | }
90 |
91 | minify () {
92 | js="$1"
93 | min="${js%.js}.min.js"
94 |
95 | terser "$js" --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' | terser --mangle --output "$min"
96 | mv "$min" "$js"
97 | }
98 |
99 | compress_assets () {
100 | if [[ "$compress" != 0 ]]; then
101 | cd "$out" && find . \( -name '*.html' -o -name '*.css' -o -name '*.js' \) -exec brotli "{}" \; -exec zopfli "{}" \;
102 | fi
103 | }
104 |
105 | clean && prepare && build_images && build_html && build_css && build_js && compress_assets
106 |
--------------------------------------------------------------------------------
/src/View/Comment.elm:
--------------------------------------------------------------------------------
1 | module View.Comment exposing (DeleteOptions, ViewOptions, view)
2 |
3 | import Data.Route as Route
4 | import Data.Timestamp as Timestamp exposing (Timestamp)
5 | import Data.Username as Username exposing (Username)
6 | import Html as H
7 | import Html.Attributes as HA
8 | import Html.Events as HE
9 | import Lib.Html.Attributes as HA
10 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
11 | import Time
12 | import Url exposing (Url)
13 |
14 |
15 | type alias ViewOptions msg =
16 | { body : NonEmptyString
17 | , username : Username
18 | , imageUrl : Url
19 | , zone : Time.Zone
20 | , createdAt : Timestamp
21 | , maybeDelete : Maybe (DeleteOptions msg)
22 | }
23 |
24 |
25 | type alias DeleteOptions msg =
26 | { isDisabled : Bool
27 | , onDelete : msg
28 | }
29 |
30 |
31 | view : ViewOptions msg -> H.Html msg
32 | view { body, username, imageUrl, zone, createdAt, maybeDelete } =
33 | let
34 | profileUrl =
35 | Route.toString <| Route.Profile username
36 | in
37 | H.div
38 | [ HA.class "card" ]
39 | [ H.div
40 | [ HA.class "card-block" ]
41 | [ H.p
42 | [ HA.class "card-text" ]
43 | [ H.text <| NonEmptyString.toString body ]
44 | ]
45 | , H.div
46 | [ HA.class "card-footer" ]
47 | [ H.a
48 | [ HA.class "comment-author"
49 | , HA.href profileUrl
50 | ]
51 | [ H.img
52 | [ HA.class "comment-author-img"
53 | , HA.src <| Url.toString imageUrl
54 | ]
55 | []
56 | ]
57 | , H.text "\u{00A0}"
58 | , H.a
59 | [ HA.class "comment-author"
60 | , HA.href profileUrl
61 | ]
62 | [ H.text <| Username.toString username
63 | ]
64 | , H.span
65 | [ HA.class "date-posted" ]
66 | [ H.text <| Timestamp.toDayAndTimeString zone createdAt ]
67 | , case maybeDelete of
68 | Just { isDisabled, onDelete } ->
69 | let
70 | isEnabled =
71 | not isDisabled
72 |
73 | attrs =
74 | HA.attrList
75 | [ HA.class "mod-options" ]
76 | [ ( HE.onClick onDelete, isEnabled )
77 | ]
78 | in
79 | H.span attrs
80 | [ H.i
81 | [ HA.class "ion-trash-a"
82 | , HA.classList [ ( "is-disabled", isDisabled ) ]
83 | ]
84 | []
85 | ]
86 |
87 | Nothing ->
88 | H.text ""
89 | ]
90 | ]
91 |
--------------------------------------------------------------------------------
/src/View/TagInput.elm:
--------------------------------------------------------------------------------
1 | module View.TagInput exposing (ViewOptions, view)
2 |
3 | import Data.Tag as Tag exposing (Tag)
4 | import Html as H
5 | import Html.Attributes as HA
6 | import Html.Events as HE
7 | import Json.Decode as JD
8 | import Lib.Html.Attributes as HA
9 | import Lib.OrderedSet as OrderedSet exposing (OrderedSet)
10 |
11 |
12 | type alias ViewOptions msg =
13 | { name : String
14 | , placeholder : String
15 | , tag : String
16 | , tags : OrderedSet Tag
17 | , isDisabled : Bool
18 | , onInput : String -> msg
19 | , onEnter : Tag -> msg
20 | , onRemove : Tag -> msg
21 | }
22 |
23 |
24 | view : ViewOptions msg -> H.Html msg
25 | view { name, placeholder, tag, tags, isDisabled, onInput, onEnter, onRemove } =
26 | let
27 | isEnabled =
28 | not isDisabled
29 |
30 | inputAttrs =
31 | HA.attrList
32 | [ HA.class "form-control form-control-lg"
33 | , HA.name name
34 | , HA.type_ "text"
35 | , HA.placeholder placeholder
36 | , HA.value tag
37 | ]
38 | [ ( HA.disabled True, isDisabled )
39 | , ( HE.onInput onInput, isEnabled )
40 | , ( onEnterKey tag onEnter, isEnabled )
41 | ]
42 | in
43 | H.fieldset
44 | [ HA.class "form-group" ]
45 | [ H.input inputAttrs []
46 | , viewTags isEnabled onRemove (OrderedSet.toList tags)
47 | ]
48 |
49 |
50 | viewTags : Bool -> (Tag -> msg) -> List Tag -> H.Html msg
51 | viewTags isEnabled onRemove =
52 | List.map
53 | (\tag ->
54 | H.span
55 | [ HA.class "tag-default tag-pill" ]
56 | [ H.i
57 | (HA.attrList
58 | [ HA.class "ion-close-round" ]
59 | [ ( HE.onClick (onRemove tag), isEnabled )
60 | ]
61 | )
62 | []
63 | , H.text <| Tag.toString tag
64 | ]
65 | )
66 | >> H.div [ HA.class "tag-list" ]
67 |
68 |
69 | onEnterKey : String -> (Tag -> msg) -> H.Attribute msg
70 | onEnterKey input toMsg =
71 | let
72 | enterKeyDecoder =
73 | HE.keyCode
74 | |> JD.andThen
75 | (\keyCode ->
76 | if keyCode == 13 then
77 | case Tag.fromString input of
78 | Just tag ->
79 | JD.succeed <| toMsg tag
80 |
81 | Nothing ->
82 | JD.fail <| "invalid tag: \"" ++ input ++ "\""
83 |
84 | else
85 | JD.fail <| "ignored keyCode: " ++ String.fromInt keyCode
86 | )
87 | |> JD.map alwaysPreventDefault
88 | in
89 | HE.preventDefaultOn "keydown" enterKeyDecoder
90 |
91 |
92 | alwaysPreventDefault : msg -> ( msg, Bool )
93 | alwaysPreventDefault msg =
94 | ( msg, True )
95 |
--------------------------------------------------------------------------------
/src/Data/Route.elm:
--------------------------------------------------------------------------------
1 | module Data.Route exposing
2 | ( Route(..)
3 | , fromUrl
4 | , logoutPath
5 | , pushUrl
6 | , redirectToArticle
7 | , redirectToHome
8 | , toString
9 | )
10 |
11 | import Browser.Navigation as BN
12 | import Data.Slug as Slug exposing (Slug)
13 | import Data.Username as Username exposing (Username)
14 | import Url exposing (Url)
15 | import Url.Builder as UB
16 | import Url.Parser as UP exposing ((>))
17 |
18 |
19 | type Route
20 | = Home
21 | | Login
22 | | Register
23 | | Settings
24 | | CreateArticle
25 | | EditArticle Slug
26 | | Article Slug
27 | | Profile Username
28 | | Favourites Username
29 |
30 |
31 | fromUrl : Url -> Maybe Route
32 | fromUrl =
33 | UP.parse routeParser
34 |
35 |
36 | routeParser : UP.Parser (Route -> a) a
37 | routeParser =
38 | UP.oneOf
39 | [ UP.map Home UP.top
40 | , UP.map Login (UP.s "login")
41 | , UP.map Register (UP.s "register")
42 | , UP.map Settings (UP.s "settings")
43 | , UP.map CreateArticle (UP.s "editor")
44 | , UP.map EditArticle (UP.s "editor" > slugParser)
45 | , UP.map Article (UP.s "article" > slugParser)
46 | , UP.map Profile (UP.s "profile" > usernameParser)
47 | , UP.map Favourites (UP.s "profile" > usernameParser > UP.s "favourites")
48 | ]
49 |
50 |
51 | slugParser : UP.Parser (Slug -> a) a
52 | slugParser =
53 | UP.custom "SLUG" (Url.percentDecode >> Maybe.andThen Slug.fromString)
54 |
55 |
56 | usernameParser : UP.Parser (Username -> a) a
57 | usernameParser =
58 | UP.custom "USERNAME" (Url.percentDecode >> Maybe.andThen Username.fromString)
59 |
60 |
61 | redirectToHome : BN.Key -> Cmd msg
62 | redirectToHome key =
63 | replaceUrl key Home
64 |
65 |
66 | redirectToArticle : BN.Key -> Slug -> Cmd msg
67 | redirectToArticle key =
68 | replaceUrl key << Article
69 |
70 |
71 | replaceUrl : BN.Key -> Route -> Cmd msg
72 | replaceUrl key =
73 | toString >> BN.replaceUrl key
74 |
75 |
76 | pushUrl : BN.Key -> Route -> Cmd msg
77 | pushUrl key =
78 | toString >> BN.pushUrl key
79 |
80 |
81 | toString : Route -> String
82 | toString route =
83 | case route of
84 | Home ->
85 | UB.absolute [] []
86 |
87 | Login ->
88 | UB.absolute [ "login" ] []
89 |
90 | Register ->
91 | UB.absolute [ "register" ] []
92 |
93 | Settings ->
94 | UB.absolute [ "settings" ] []
95 |
96 | CreateArticle ->
97 | UB.absolute [ "editor" ] []
98 |
99 | EditArticle slug ->
100 | UB.absolute [ "editor", Slug.toString slug ] []
101 |
102 | Article slug ->
103 | UB.absolute [ "article", Slug.toString slug ] []
104 |
105 | Profile username ->
106 | UB.absolute [ "profile", Username.toString username ] []
107 |
108 | Favourites username ->
109 | UB.absolute [ "profile", Username.toString username, "favourites" ] []
110 |
111 |
112 | logoutPath : String
113 | logoutPath =
114 | UB.absolute [ "logout" ] []
115 |
--------------------------------------------------------------------------------
/prototype/header.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Home — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Header
17 |
18 | Unauthenticated user
19 |
20 | If no user is logged in, then the header should include links to:
21 |
22 |
23 | - the home page
24 | - the login page
25 | - the register page
26 |
27 |
28 | The link of the active page should use the active CSS class.
29 |
30 |
31 |
32 |
49 |
50 |
51 |
52 | Authenticated user
53 |
54 | If a user is logged in, then the header should include links to:
55 |
56 |
57 | - the home page
58 | - the new article page
59 | - the settings page
60 | - the profile page
61 |
62 |
63 | The link of the active page should use the active CSS class.
64 |
65 |
66 |
67 |
90 |
91 |
92 |
93 |
94 |
--------------------------------------------------------------------------------
/src/Data/Validation.elm:
--------------------------------------------------------------------------------
1 | module Data.Validation exposing
2 | ( bio
3 | , email
4 | , imageUrl
5 | , nonEmptyString
6 | , optionalPassword
7 | , password
8 | , tags
9 | , username
10 | )
11 |
12 | import Data.Email as Email exposing (Email)
13 | import Data.Password as Password exposing (Password)
14 | import Data.Tag exposing (Tag)
15 | import Data.Username as Username exposing (Username)
16 | import Lib.NonEmptyString as NonEmptyString exposing (NonEmptyString)
17 | import Lib.OrderedSet as OrderedSet exposing (OrderedSet)
18 | import Lib.String as String
19 | import Lib.Validation as V
20 | import Url exposing (Url)
21 |
22 |
23 | bio : String -> V.Validation String
24 | bio =
25 | V.succeed
26 |
27 |
28 | email : String -> V.Validation Email
29 | email s =
30 | case Email.fromString s of
31 | Just validEmail ->
32 | V.succeed validEmail
33 |
34 | Nothing ->
35 | V.fail "email can't be blank"
36 |
37 |
38 | imageUrl : String -> V.Validation Url
39 | imageUrl s =
40 | let
41 | t =
42 | String.trim s
43 | in
44 | if String.isEmpty t then
45 | V.fail "image can't be blank"
46 |
47 | else
48 | case Url.fromString t of
49 | Just validUrl ->
50 | V.succeed validUrl
51 |
52 | Nothing ->
53 | V.fail "image is invalid"
54 |
55 |
56 | nonEmptyString : String -> String -> V.Validation NonEmptyString
57 | nonEmptyString fieldName s =
58 | case NonEmptyString.fromString s of
59 | Just validField ->
60 | V.succeed validField
61 |
62 | Nothing ->
63 | V.fail <| fieldName ++ " can't be blank"
64 |
65 |
66 | optionalPassword : String -> V.Validation (Maybe Password)
67 | optionalPassword s =
68 | case Password.fromString s of
69 | Ok validPassword ->
70 | V.succeed <| Just validPassword
71 |
72 | Err Password.Blank ->
73 | V.succeed Nothing
74 |
75 | Err (Password.TooShort expectedLength) ->
76 | V.fail <| passwordTooShortMessage expectedLength
77 |
78 |
79 | password : String -> V.Validation Password
80 | password s =
81 | case Password.fromString s of
82 | Ok validPassword ->
83 | V.succeed validPassword
84 |
85 | Err Password.Blank ->
86 | V.fail "password can't be blank"
87 |
88 | Err (Password.TooShort expectedLength) ->
89 | V.fail <| passwordTooShortMessage expectedLength
90 |
91 |
92 | passwordTooShortMessage : Int -> String
93 | passwordTooShortMessage expectedLength =
94 | String.concat
95 | [ "password must be at least "
96 | , String.fromInt expectedLength
97 | , " "
98 | , String.pluralize
99 | expectedLength
100 | { singular = "character"
101 | , plural = "characters"
102 | }
103 | , " long"
104 | ]
105 |
106 |
107 | tags : OrderedSet Tag -> V.Validation (List Tag)
108 | tags =
109 | V.succeed << OrderedSet.toList
110 |
111 |
112 | username : String -> V.Validation Username
113 | username s =
114 | case Username.fromString s of
115 | Just validUsername ->
116 | V.succeed validUsername
117 |
118 | Nothing ->
119 | V.fail "username can't be blank"
120 |
--------------------------------------------------------------------------------
/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
75 |
76 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/tests/Test/Data/Pager.elm:
--------------------------------------------------------------------------------
1 | module Test.Data.Pager exposing (suite)
2 |
3 | import Data.Offset as Offset
4 | import Data.PageNumber as PageNumber
5 | import Data.Pager as Pager exposing (Pager)
6 | import Data.Total as Total
7 | import Expect
8 | import Test exposing (Test, describe, test)
9 |
10 |
11 |
12 | --
13 | -- N.B.: In all the test cases below we use a page limit of 5.
14 | --
15 |
16 |
17 | suite : Test
18 | suite =
19 | describe "Data.Pager"
20 | [ toTotalPagesSuite
21 | , toPageSuite
22 | ]
23 |
24 |
25 | toTotalPagesSuite : Test
26 | toTotalPagesSuite =
27 | let
28 | examples =
29 | --
30 | -- [ ( totalItems, totalPages ), ... ]
31 | --
32 | [ ( 0, 0 )
33 | , ( 1, 1 )
34 | , ( 2, 1 )
35 | , ( 3, 1 )
36 | , ( 4, 1 )
37 | , ( 5, 1 )
38 | , ( 6, 2 )
39 | , ( 7, 2 )
40 | , ( 8, 2 )
41 | , ( 9, 2 )
42 | , ( 10, 2 )
43 | , ( 11, 3 )
44 | , ( 20, 4 )
45 | , ( 21, 5 )
46 | , ( 100, 20 )
47 | ]
48 | in
49 | describe "toTotalPages" <|
50 | List.map
51 | (\( totalItems, totalPages ) ->
52 | let
53 | description =
54 | "{ totalItems = " ++ String.fromInt totalItems ++ ", totalPages = " ++ String.fromInt totalPages ++ " }"
55 | in
56 | test description <|
57 | \_ ->
58 | newPager totalItems
59 | |> toTotalPages
60 | |> Expect.equal totalPages
61 | )
62 | examples
63 |
64 |
65 | toPageSuite : Test
66 | toPageSuite =
67 | let
68 | pager =
69 | --
70 | -- 20 pages
71 | --
72 | newPager 100
73 |
74 | examples =
75 | --
76 | -- [ ( pageNumber, offset ), ... ]
77 | --
78 | [ ( 1, 0 )
79 | , ( 2, 5 )
80 | , ( 3, 10 )
81 | , ( 4, 15 )
82 | , ( 20, 95 )
83 |
84 | --
85 | -- N.B. Even though page 21 doesn't exist we can still compute the offset.
86 | --
87 | , ( 21, 100 )
88 | ]
89 | in
90 | describe "toPage" <|
91 | List.map
92 | (\( pageNumber, offset ) ->
93 | let
94 | description =
95 | "{ pageNumber = " ++ String.fromInt pageNumber ++ ", offset = " ++ String.fromInt offset ++ " }"
96 | in
97 | test description <|
98 | \_ ->
99 | pager
100 | |> toOffset pageNumber
101 | |> Expect.equal offset
102 | )
103 | examples
104 |
105 |
106 |
107 | -- HELPERS
108 |
109 |
110 | newPager : Int -> Pager
111 | newPager n =
112 | let
113 | totalItems =
114 | Total.fromInt n
115 | in
116 | Pager.five
117 | |> Pager.setTotalPages totalItems
118 |
119 |
120 | toTotalPages : Pager -> Int
121 | toTotalPages =
122 | Pager.toTotalPages >> Total.toInt
123 |
124 |
125 | toOffset : Int -> Pager -> Int
126 | toOffset n =
127 | let
128 | pageNumber =
129 | PageNumber.fromInt n
130 | in
131 | Pager.toPage pageNumber >> .offset >> Offset.toInt
132 |
--------------------------------------------------------------------------------
/src/View/ArticlePreview.elm:
--------------------------------------------------------------------------------
1 | module View.ArticlePreview exposing (Role(..), ViewOptions, view, viewMessage)
2 |
3 | import Data.Route as Route
4 | import Data.Slug exposing (Slug)
5 | import Data.Tag as Tag exposing (Tag)
6 | import Data.Timestamp as Timestamp exposing (Timestamp)
7 | import Data.Total as Total exposing (Total)
8 | import Data.Username as Username exposing (Username)
9 | import Html as H
10 | import Html.Attributes as HA
11 | import Html.Events as HE
12 | import Time
13 | import Url exposing (Url)
14 |
15 |
16 | type alias ViewOptions msg =
17 | { role : Role msg
18 | , username : Username
19 | , imageUrl : Url
20 | , zone : Time.Zone
21 | , createdAt : Timestamp
22 | , slug : Slug
23 | , title : String
24 | , description : String
25 | , tags : List Tag
26 | }
27 |
28 |
29 | type Role msg
30 | = Guest
31 | | User
32 | { isLoading : Bool
33 | , totalFavourites : Total
34 | , isFavourite : Bool
35 | , onToggleFavourite : Bool -> msg
36 | }
37 |
38 |
39 | view : ViewOptions msg -> H.Html msg
40 | view { role, username, imageUrl, zone, createdAt, slug, title, description, tags } =
41 | let
42 | profileHref =
43 | Route.toString <| Route.Profile username
44 | in
45 | H.div
46 | [ HA.class "article-preview" ]
47 | [ H.div
48 | [ HA.class "article-meta" ]
49 | [ H.a
50 | [ HA.href profileHref ]
51 | [ H.img
52 | [ HA.src <| Url.toString imageUrl ]
53 | []
54 | ]
55 | , H.div
56 | [ HA.class "info" ]
57 | [ H.a
58 | [ HA.class "author"
59 | , HA.href profileHref
60 | ]
61 | [ H.text <| Username.toString username ]
62 | , H.span
63 | [ HA.class "date" ]
64 | [ H.text <| Timestamp.toDayString zone createdAt ]
65 | ]
66 | , case role of
67 | Guest ->
68 | H.text ""
69 |
70 | User { isLoading, totalFavourites, isFavourite, onToggleFavourite } ->
71 | H.button
72 | [ HA.class "btn btn-sm pull-xs-right"
73 | , HA.class <|
74 | if isFavourite then
75 | "btn-primary"
76 |
77 | else
78 | "btn-outline-primary"
79 | , if isLoading then
80 | HA.disabled True
81 |
82 | else
83 | HE.onClick (onToggleFavourite <| not isFavourite)
84 | ]
85 | [ H.i [ HA.class "ion-heart" ] []
86 | , H.text " "
87 | , H.text <| Total.toString totalFavourites
88 | ]
89 | ]
90 | , H.a
91 | [ HA.class "preview-link"
92 | , HA.href <| Route.toString <| Route.Article slug
93 | ]
94 | [ H.h1 [] [ H.text title ]
95 | , H.p [] [ H.text description ]
96 | , H.span [] [ H.text "Read more..." ]
97 | , H.ul [ HA.class "tag-list" ] <|
98 | List.map
99 | (\tag ->
100 | H.li
101 | [ HA.class "tag-default tag-pill tag-outline" ]
102 | [ H.text <| Tag.toString tag ]
103 | )
104 | tags
105 | ]
106 | ]
107 |
108 |
109 | viewMessage : String -> H.Html msg
110 | viewMessage message =
111 | H.div
112 | [ HA.class "article-preview" ]
113 | [ H.text message ]
114 |
--------------------------------------------------------------------------------
/tests/Test/Data/Comments.elm:
--------------------------------------------------------------------------------
1 | module Test.Data.Comments exposing (suite)
2 |
3 | import Data.Comment exposing (Comment)
4 | import Data.Comments as Comments
5 | import Data.Timestamp as Timestamp
6 | import Expect
7 | import Fuzz exposing (Fuzzer)
8 | import Json.Decode as JD
9 | import Json.Encode as JE
10 | import Test exposing (Test, describe, fuzz)
11 |
12 |
13 | suite : Test
14 | suite =
15 | describe "Data.Comments"
16 | [ describe "decoder"
17 | [ fuzz commentsFuzzer "sorts comments in reverse chronological order" <|
18 | \( n, value ) ->
19 | case JD.decodeValue Comments.decoder value of
20 | Ok comments ->
21 | let
22 | listOfComments =
23 | Comments.toList comments
24 | in
25 | ( List.length listOfComments
26 | , isReverseChronological listOfComments
27 | )
28 | |> Expect.equal ( n, True )
29 |
30 | Err error ->
31 | Expect.fail (JD.errorToString error)
32 | ]
33 | ]
34 |
35 |
36 |
37 | -- FUZZERS
38 |
39 |
40 | commentsFuzzer : Fuzzer ( Int, JE.Value )
41 | commentsFuzzer =
42 | Fuzz.intRange 0 maxTotalMilliseconds
43 | |> Fuzz.map (\n -> newComment n (msToTimestamp "2024-01-01" n))
44 | |> Fuzz.list
45 | |> Fuzz.map (\l -> ( List.length l, JE.list identity l ))
46 |
47 |
48 |
49 | -- HELPERS
50 |
51 |
52 | isReverseChronological : List Comment -> Bool
53 | isReverseChronological comments =
54 | case comments of
55 | [] ->
56 | True
57 |
58 | [ _ ] ->
59 | True
60 |
61 | commentA :: commentB :: restOfComments ->
62 | case Timestamp.compare commentA.createdAt commentB.createdAt of
63 | LT ->
64 | False
65 |
66 | _ ->
67 | isReverseChronological (commentB :: restOfComments)
68 |
69 |
70 | newComment : Int -> String -> JE.Value
71 | newComment id createdAt =
72 | JE.object
73 | [ ( "id", JE.int id )
74 | , ( "createdAt", JE.string createdAt )
75 | , ( "body", JE.string "A comment." )
76 | , ( "author"
77 | , JE.object
78 | [ ( "username", JE.string "Eric Simons" )
79 | , ( "image", JE.string "http://i.imgur.com/Qr71crq.jpg" )
80 | ]
81 | )
82 | ]
83 |
84 |
85 | maxTotalMilliseconds : Int
86 | maxTotalMilliseconds =
87 | --
88 | -- What is the most that totalMillisSeconds (see msToTimestamp) can be?
89 | --
90 | -- 23 * 3600000 + 59 * 60000 + 59 * 1000 + 999
91 | -- = 86399999
92 | --
93 | 86399999
94 |
95 |
96 | msToTimestamp : String -> Int -> String
97 | msToTimestamp date totalMilliseconds =
98 | --
99 | -- Preconditions:
100 | --
101 | -- 1. date MUST be in the format YYYY-MM-DD.
102 | -- 2. 0 <= totalMilliseconds <= maxTotalMilliseconds.
103 | --
104 | let
105 | hrs =
106 | totalMilliseconds // 3600000
107 |
108 | totalMillisecondsAfterHrs =
109 | totalMilliseconds |> modBy 3600000
110 |
111 | mins =
112 | totalMillisecondsAfterHrs // 60000
113 |
114 | totalMillisecondsAfterMins =
115 | totalMillisecondsAfterHrs |> modBy 60000
116 |
117 | secs =
118 | totalMillisecondsAfterMins // 1000
119 |
120 | ms =
121 | totalMillisecondsAfterMins |> modBy 1000
122 | in
123 | String.concat
124 | [ date
125 | , "T"
126 | , toNDigits 2 hrs
127 | , ":"
128 | , toNDigits 2 mins
129 | , ":"
130 | , toNDigits 2 secs
131 | , "."
132 | , toNDigits 3 ms
133 | , "Z"
134 | ]
135 |
136 |
137 | toNDigits : Int -> Int -> String
138 | toNDigits n =
139 | String.fromInt >> String.padLeft n '0'
140 |
--------------------------------------------------------------------------------
/src/Api/GetArticles.elm:
--------------------------------------------------------------------------------
1 | module Api.GetArticles exposing
2 | ( Articles
3 | , Options
4 | , Request
5 | , byAuthor
6 | , byFavourites
7 | , byTag
8 | , fromUsersYouFollow
9 | , getArticles
10 | , global
11 | )
12 |
13 | import Api
14 | import Data.Article as Article exposing (Article)
15 | import Data.Limit as Limit
16 | import Data.Offset as Offset
17 | import Data.Pager exposing (Page)
18 | import Data.Tag as Tag exposing (Tag)
19 | import Data.Token exposing (Token)
20 | import Data.Total as Total exposing (Total)
21 | import Data.Username as Username exposing (Username)
22 | import Json.Decode as JD
23 | import Url exposing (Url)
24 | import Url.Builder as UB
25 |
26 |
27 | type alias Options msg =
28 | { request : Request
29 | , page : Page
30 | , onResponse : Result (Api.Error ()) Articles -> msg
31 | }
32 |
33 |
34 | type Request
35 | = FromUsersYouFollow Token
36 | | Global (Maybe Token) Filter
37 |
38 |
39 | type Filter
40 | = None
41 | | ByTag Tag
42 | | ByAuthor Username
43 | | ByFavourites Username
44 |
45 |
46 | fromUsersYouFollow : Token -> Request
47 | fromUsersYouFollow =
48 | FromUsersYouFollow
49 |
50 |
51 | global : Maybe Token -> Request
52 | global maybeToken =
53 | Global maybeToken None
54 |
55 |
56 | byTag : Maybe Token -> Tag -> Request
57 | byTag maybeToken =
58 | Global maybeToken << ByTag
59 |
60 |
61 | byAuthor : Maybe Token -> Username -> Request
62 | byAuthor maybeToken =
63 | Global maybeToken << ByAuthor
64 |
65 |
66 | byFavourites : Maybe Token -> Username -> Request
67 | byFavourites maybeToken =
68 | Global maybeToken << ByFavourites
69 |
70 |
71 | getArticles : Url -> Options msg -> Cmd msg
72 | getArticles baseUrl { request, page, onResponse } =
73 | case request of
74 | FromUsersYouFollow token ->
75 | getArticlesFromUsersYouFollow baseUrl token page onResponse
76 |
77 | Global maybeToken filter ->
78 | getArticlesGlobally baseUrl maybeToken filter page onResponse
79 |
80 |
81 | getArticlesFromUsersYouFollow : Url -> Token -> Page -> (Result (Api.Error ()) Articles -> msg) -> Cmd msg
82 | getArticlesFromUsersYouFollow baseUrl token page onResponse =
83 | Api.get
84 | { maybeToken = Just token
85 | , url =
86 | Api.buildUrl
87 | baseUrl
88 | [ "articles", "feed" ]
89 | [ UB.int "offset" <| Offset.toInt page.offset
90 | , UB.int "limit" <| Limit.toInt page.limit
91 | ]
92 | []
93 | , onResponse = onResponse
94 | , decoder = decoder
95 | }
96 |
97 |
98 | getArticlesGlobally : Url -> Maybe Token -> Filter -> Page -> (Result (Api.Error ()) Articles -> msg) -> Cmd msg
99 | getArticlesGlobally baseUrl maybeToken filter page onResponse =
100 | Api.get
101 | { maybeToken = maybeToken
102 | , url =
103 | Api.buildUrl
104 | baseUrl
105 | [ "articles" ]
106 | [ UB.int "offset" <| Offset.toInt page.offset
107 | , UB.int "limit" <| Limit.toInt page.limit
108 | ]
109 | [ case filter of
110 | None ->
111 | Nothing
112 |
113 | ByTag tag ->
114 | Just <| UB.string "tag" <| Tag.toString tag
115 |
116 | ByAuthor username ->
117 | Just <| UB.string "author" <| Username.toString username
118 |
119 | ByFavourites username ->
120 | Just <| UB.string "favorited" <| Username.toString username
121 | ]
122 | , onResponse = onResponse
123 | , decoder = decoder
124 | }
125 |
126 |
127 | type alias Articles =
128 | { articles : List Article
129 | , totalArticles : Total
130 | }
131 |
132 |
133 | decoder : JD.Decoder Articles
134 | decoder =
135 | JD.map2 Articles
136 | (JD.field "articles" <| JD.list Article.decoder)
137 | (JD.field "articlesCount" Total.decoder)
138 |
--------------------------------------------------------------------------------
/src/View/ArticleMeta.elm:
--------------------------------------------------------------------------------
1 | module View.ArticleMeta exposing
2 | ( AuthorOptions
3 | , Role(..)
4 | , UserOptions
5 | , ViewOptions
6 | , view
7 | )
8 |
9 | import Data.Route as Route
10 | import Data.Slug exposing (Slug)
11 | import Data.Timestamp as Timestamp exposing (Timestamp)
12 | import Data.Total exposing (Total)
13 | import Data.Username as Username exposing (Username)
14 | import Html as H
15 | import Html.Attributes as HA
16 | import Html.Events as HE
17 | import Time
18 | import Url exposing (Url)
19 | import View.FavouriteButton as FavouriteButton
20 | import View.FollowButton as FollowButton
21 |
22 |
23 | type alias ViewOptions msg =
24 | { username : Username
25 | , imageUrl : Url
26 | , zone : Time.Zone
27 | , createdAt : Timestamp
28 | , role : Role msg
29 | }
30 |
31 |
32 | type Role msg
33 | = Guest
34 | | User (UserOptions msg)
35 | | Author (AuthorOptions msg)
36 |
37 |
38 | type alias UserOptions msg =
39 | { isDisabled : Bool
40 | , isFollowing : Bool
41 | , onFollow : msg
42 | , onUnfollow : msg
43 | , isFavourite : Bool
44 | , totalFavourites : Total
45 | , onFavourite : msg
46 | , onUnfavourite : msg
47 | }
48 |
49 |
50 | type alias AuthorOptions msg =
51 | { isDisabled : Bool
52 | , slug : Slug
53 | , onDelete : Slug -> msg
54 | }
55 |
56 |
57 | view : ViewOptions msg -> H.Html msg
58 | view { username, imageUrl, zone, createdAt, role } =
59 | let
60 | profileUrl =
61 | Route.toString <| Route.Profile username
62 |
63 | buttons =
64 | case role of
65 | Guest ->
66 | []
67 |
68 | User { isDisabled, isFollowing, onFollow, onUnfollow, isFavourite, totalFavourites, onFavourite, onUnfavourite } ->
69 | [ FollowButton.view
70 | { username = username
71 | , isFollowing = isFollowing
72 | , maybeTotalFollowers = Nothing
73 | , isDisabled = isDisabled
74 | , onFollow = onFollow
75 | , onUnfollow = onUnfollow
76 | }
77 | , viewSpace
78 | , FavouriteButton.view
79 | { isFavourite = isFavourite
80 | , totalFavourites = totalFavourites
81 | , isDisabled = isDisabled
82 | , onFavourite = onFavourite
83 | , onUnfavourite = onUnfavourite
84 | }
85 | ]
86 |
87 | Author { isDisabled, slug, onDelete } ->
88 | [ H.a
89 | [ HA.class "btn btn-sm btn-outline-secondary"
90 | , HA.href <| Route.toString <| Route.EditArticle slug
91 | ]
92 | [ H.i [ HA.class "ion-edit" ] []
93 | , H.text "\u{00A0} Edit Article"
94 | ]
95 | , viewSpace
96 | , H.button
97 | [ HA.class "btn btn-sm btn-outline-danger"
98 | , if isDisabled then
99 | HA.disabled True
100 |
101 | else
102 | HE.onClick <| onDelete slug
103 | ]
104 | [ H.i [ HA.class "ion-trash-a" ] []
105 | , H.text "\u{00A0} Delete Article"
106 | ]
107 | ]
108 | in
109 | H.div [ HA.class "article-meta" ] <|
110 | List.append
111 | [ H.a
112 | [ HA.href profileUrl ]
113 | [ H.img [ HA.src <| Url.toString imageUrl ] [] ]
114 | , H.div
115 | [ HA.class "info" ]
116 | [ H.a
117 | [ HA.class "author"
118 | , HA.href profileUrl
119 | ]
120 | [ H.text <| Username.toString username ]
121 | , H.span
122 | [ HA.class "date" ]
123 | [ H.text <| Timestamp.toDayString zone createdAt ]
124 | ]
125 | ]
126 | buttons
127 |
128 |
129 | viewSpace : H.Html msg
130 | viewSpace =
131 | H.text "\u{00A0}\u{00A0}"
132 |
--------------------------------------------------------------------------------
/src/Page/Login.elm:
--------------------------------------------------------------------------------
1 | module Page.Login exposing (InitOptions, Model, Msg, UpdateOptions, ViewOptions, init, update, view)
2 |
3 | import Api
4 | import Api.Login as Login
5 | import Browser as B
6 | import Data.Email exposing (Email)
7 | import Data.Password exposing (Password)
8 | import Data.User exposing (User)
9 | import Data.Validation as V
10 | import Html as H
11 | import Lib.Browser.Dom as BD
12 | import Lib.Task as Task
13 | import Lib.Validation as V
14 | import Url exposing (Url)
15 | import View.Column as Column
16 | import View.Layout as Layout
17 | import View.Login as Login
18 | import View.Navigation as Navigation
19 |
20 |
21 |
22 | -- MODEL
23 |
24 |
25 | type alias Model =
26 | { email : String
27 | , password : String
28 | , errorMessages : List String
29 | , isDisabled : Bool
30 | }
31 |
32 |
33 | type alias InitOptions msg =
34 | { onChange : Msg -> msg
35 | }
36 |
37 |
38 | init : InitOptions msg -> ( Model, Cmd msg )
39 | init { onChange } =
40 | ( initModel
41 | , BD.focus "email" FocusedEmail
42 | |> Cmd.map onChange
43 | )
44 |
45 |
46 | initModel : Model
47 | initModel =
48 | { email = ""
49 | , password = ""
50 | , errorMessages = []
51 | , isDisabled = False
52 | }
53 |
54 |
55 |
56 | -- UPDATE
57 |
58 |
59 | type alias UpdateOptions msg =
60 | { apiUrl : Url
61 | , onLoggedIn : User -> msg
62 | , onChange : Msg -> msg
63 | }
64 |
65 |
66 | type Msg
67 | = FocusedEmail
68 | | ChangedEmail String
69 | | ChangedPassword String
70 | | SubmittedForm
71 | | GotLoginResponse (Result (Api.Error (List String)) User)
72 |
73 |
74 | update : UpdateOptions msg -> Msg -> Model -> ( Model, Cmd msg )
75 | update options msg model =
76 | case msg of
77 | FocusedEmail ->
78 | ( model, Cmd.none )
79 |
80 | ChangedEmail email ->
81 | ( { model | email = email }
82 | , Cmd.none
83 | )
84 |
85 | ChangedPassword password ->
86 | ( { model | password = password }
87 | , Cmd.none
88 | )
89 |
90 | SubmittedForm ->
91 | validate model
92 | |> V.withValidation
93 | { onSuccess =
94 | \{ email, password } ->
95 | ( { model | errorMessages = [], isDisabled = True }
96 | , Login.login
97 | options.apiUrl
98 | { email = email
99 | , password = password
100 | , onResponse = GotLoginResponse
101 | }
102 | |> Cmd.map options.onChange
103 | )
104 | , onFailure =
105 | \errorMessages ->
106 | ( { model | errorMessages = errorMessages }
107 | , Cmd.none
108 | )
109 | }
110 |
111 | GotLoginResponse result ->
112 | Api.handleFormResponse
113 | (\user ->
114 | ( initModel
115 | , Task.dispatch (options.onLoggedIn user)
116 | )
117 | )
118 | model
119 | result
120 |
121 |
122 | type alias ValidatedFields =
123 | { email : Email
124 | , password : Password
125 | }
126 |
127 |
128 | validate : Model -> V.Validation ValidatedFields
129 | validate { email, password } =
130 | V.succeed ValidatedFields
131 | |> V.apply (V.email email)
132 | |> V.apply (V.password password)
133 |
134 |
135 |
136 | -- VIEW
137 |
138 |
139 | type alias ViewOptions msg =
140 | { onChange : Msg -> msg
141 | }
142 |
143 |
144 | view : ViewOptions msg -> Model -> B.Document msg
145 | view { onChange } { email, password, errorMessages, isDisabled } =
146 | { title = "Login"
147 | , body =
148 | [ Layout.view
149 | { name = "auth"
150 | , role = Navigation.login
151 | , maybeHeader = Nothing
152 | }
153 | [ Column.viewSingle Column.ExtraSmall
154 | [ Login.view
155 | { form =
156 | { email = email
157 | , password = password
158 | , isDisabled = isDisabled
159 | , onInputEmail = ChangedEmail
160 | , onInputPassword = ChangedPassword
161 | , onSubmit = SubmittedForm
162 | }
163 | , errorMessages = errorMessages
164 | }
165 | ]
166 | ]
167 | |> H.map onChange
168 | ]
169 | }
170 |
--------------------------------------------------------------------------------
/prototype/profile.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | @Eric Simons — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |

22 |
Eric Simons
23 |
24 | Cofounder @GoThinkster, lived in Aol's HQ for a few months, kinda looks like Peeta from the Hunger Games
25 |
26 |
27 |
32 |
36 |
40 |
41 |
42 | Edit Profile Settings
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
62 |
63 |
84 |
85 |
106 |
107 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
--------------------------------------------------------------------------------
/prototype/home.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Home — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Home
17 |
18 | The home page includes up to three tabs:
19 |
20 |
21 | - a default Global Feed tab
22 | - an optional tag name tab, appears after clicking one of the popular tags
23 | - an optional Your Feed tab, appears after logging in
24 |
25 |
26 |
27 |
28 |
29 |
conduit
30 |
A place to share your knowledge.
31 |
32 |
33 |
34 |
35 |
36 |
37 |
47 |
48 |
69 |
70 |
91 |
92 |
100 |
101 |
102 |
103 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
--------------------------------------------------------------------------------
/src/Page/Register.elm:
--------------------------------------------------------------------------------
1 | module Page.Register exposing (InitOptions, Model, Msg, UpdateOptions, ViewOptions, init, update, view)
2 |
3 | import Api
4 | import Api.Register as Register
5 | import Browser as B
6 | import Data.Email exposing (Email)
7 | import Data.Password exposing (Password)
8 | import Data.User exposing (User)
9 | import Data.Username exposing (Username)
10 | import Data.Validation as V
11 | import Html as H
12 | import Lib.Browser.Dom as BD
13 | import Lib.Task as Task
14 | import Lib.Validation as V
15 | import Url exposing (Url)
16 | import View.Column as Column
17 | import View.Layout as Layout
18 | import View.Navigation as Navigation
19 | import View.Register as Register
20 |
21 |
22 |
23 | -- MODEL
24 |
25 |
26 | type alias Model =
27 | { username : String
28 | , email : String
29 | , password : String
30 | , errorMessages : List String
31 | , isDisabled : Bool
32 | }
33 |
34 |
35 | type alias InitOptions msg =
36 | { onChange : Msg -> msg
37 | }
38 |
39 |
40 | init : InitOptions msg -> ( Model, Cmd msg )
41 | init { onChange } =
42 | ( initModel
43 | , BD.focus "username" FocusedUsername
44 | |> Cmd.map onChange
45 | )
46 |
47 |
48 | initModel : Model
49 | initModel =
50 | { username = ""
51 | , email = ""
52 | , password = ""
53 | , errorMessages = []
54 | , isDisabled = False
55 | }
56 |
57 |
58 |
59 | -- UPDATE
60 |
61 |
62 | type alias UpdateOptions msg =
63 | { apiUrl : Url
64 | , onRegistered : User -> msg
65 | , onChange : Msg -> msg
66 | }
67 |
68 |
69 | type Msg
70 | = FocusedUsername
71 | | ChangedUsername String
72 | | ChangedEmail String
73 | | ChangedPassword String
74 | | SubmittedForm
75 | | GotRegisterResponse (Result (Api.Error (List String)) User)
76 |
77 |
78 | update : UpdateOptions msg -> Msg -> Model -> ( Model, Cmd msg )
79 | update options msg model =
80 | case msg of
81 | FocusedUsername ->
82 | ( model, Cmd.none )
83 |
84 | ChangedUsername username ->
85 | ( { model | username = username }
86 | , Cmd.none
87 | )
88 |
89 | ChangedEmail email ->
90 | ( { model | email = email }
91 | , Cmd.none
92 | )
93 |
94 | ChangedPassword password ->
95 | ( { model | password = password }
96 | , Cmd.none
97 | )
98 |
99 | SubmittedForm ->
100 | validate model
101 | |> V.withValidation
102 | { onSuccess =
103 | \{ username, email, password } ->
104 | ( { model | errorMessages = [], isDisabled = True }
105 | , Register.register
106 | options.apiUrl
107 | { username = username
108 | , email = email
109 | , password = password
110 | , onResponse = GotRegisterResponse
111 | }
112 | |> Cmd.map options.onChange
113 | )
114 | , onFailure =
115 | \errorMessages ->
116 | ( { model | errorMessages = errorMessages }
117 | , Cmd.none
118 | )
119 | }
120 |
121 | GotRegisterResponse result ->
122 | Api.handleFormResponse
123 | (\user ->
124 | ( initModel
125 | , Task.dispatch (options.onRegistered user)
126 | )
127 | )
128 | model
129 | result
130 |
131 |
132 | type alias ValidatedFields =
133 | { username : Username
134 | , email : Email
135 | , password : Password
136 | }
137 |
138 |
139 | validate : Model -> V.Validation ValidatedFields
140 | validate { username, email, password } =
141 | V.succeed ValidatedFields
142 | |> V.apply (V.username username)
143 | |> V.apply (V.email email)
144 | |> V.apply (V.password password)
145 |
146 |
147 |
148 | -- VIEW
149 |
150 |
151 | type alias ViewOptions msg =
152 | { onChange : Msg -> msg
153 | }
154 |
155 |
156 | view : ViewOptions msg -> Model -> B.Document msg
157 | view { onChange } { username, email, password, errorMessages, isDisabled } =
158 | { title = "Register"
159 | , body =
160 | [ Layout.view
161 | { name = "auth"
162 | , role = Navigation.register
163 | , maybeHeader = Nothing
164 | }
165 | [ Column.viewSingle Column.ExtraSmall
166 | [ Register.view
167 | { form =
168 | { username = username
169 | , email = email
170 | , password = password
171 | , isDisabled = isDisabled
172 | , onInputUsername = ChangedUsername
173 | , onInputEmail = ChangedEmail
174 | , onInputPassword = ChangedPassword
175 | , onSubmit = SubmittedForm
176 | }
177 | , errorMessages = errorMessages
178 | }
179 | ]
180 | ]
181 | |> H.map onChange
182 | ]
183 | }
184 |
--------------------------------------------------------------------------------
/src/View/Navigation.elm:
--------------------------------------------------------------------------------
1 | module View.Navigation exposing
2 | ( Role
3 | , UserDetails
4 | , ViewOptions
5 | , guest
6 | , guestHome
7 | , login
8 | , newArticle
9 | , profile
10 | , register
11 | , settings
12 | , user
13 | , userHome
14 | , view
15 | )
16 |
17 | import Data.Route as Route exposing (Route)
18 | import Data.Username as Username exposing (Username)
19 | import Html as H
20 | import Html.Attributes as HA
21 | import Html.Events as HE
22 | import Json.Decode as JD
23 | import Url exposing (Url)
24 |
25 |
26 | type alias ViewOptions msg =
27 | { role : Role msg
28 | }
29 |
30 |
31 | type Role msg
32 | = Guest (Maybe GuestItem)
33 | | User (Maybe UserItem) (UserDetails msg)
34 |
35 |
36 | type alias UserDetails msg =
37 | { username : Username
38 | , imageUrl : Url
39 | , onLogout : msg
40 | }
41 |
42 |
43 | type GuestItem
44 | = GuestHome
45 | | Login
46 | | Register
47 |
48 |
49 | type UserItem
50 | = UserHome
51 | | NewArticle
52 | | Settings
53 | | Profile
54 |
55 |
56 | guest : Role msg
57 | guest =
58 | Guest Nothing
59 |
60 |
61 | guestHome : Role msg
62 | guestHome =
63 | Guest <| Just GuestHome
64 |
65 |
66 | login : Role msg
67 | login =
68 | Guest <| Just Login
69 |
70 |
71 | register : Role msg
72 | register =
73 | Guest <| Just Register
74 |
75 |
76 | user : UserDetails msg -> Role msg
77 | user =
78 | User Nothing
79 |
80 |
81 | userHome : UserDetails msg -> Role msg
82 | userHome =
83 | User (Just UserHome)
84 |
85 |
86 | newArticle : UserDetails msg -> Role msg
87 | newArticle =
88 | User (Just NewArticle)
89 |
90 |
91 | settings : UserDetails msg -> Role msg
92 | settings =
93 | User (Just Settings)
94 |
95 |
96 | profile : UserDetails msg -> Role msg
97 | profile =
98 | User (Just Profile)
99 |
100 |
101 | view : ViewOptions msg -> H.Html msg
102 | view { role } =
103 | H.nav
104 | [ HA.class "navbar navbar-light" ]
105 | [ H.div
106 | [ HA.class "container" ]
107 | [ H.a
108 | [ HA.class "navbar-brand"
109 | , HA.href <| Route.toString Route.Home
110 | ]
111 | [ H.text "conduit" ]
112 | , viewNavItems role
113 | ]
114 | ]
115 |
116 |
117 | viewNavItems : Role msg -> H.Html msg
118 | viewNavItems role =
119 | let
120 | navLinks =
121 | case role of
122 | Guest maybeItem ->
123 | guestNavLinks maybeItem
124 |
125 | User maybeItem userDetails ->
126 | userNavLinks maybeItem userDetails
127 | in
128 | H.ul [ HA.class "nav navbar-nav pull-xs-right" ] <|
129 | List.map
130 | (\( isActive, navLink ) ->
131 | H.li
132 | [ HA.class "nav-item" ]
133 | [ viewNavLink isActive navLink
134 | ]
135 | )
136 | navLinks
137 |
138 |
139 | type NavLink msg
140 | = Text
141 | { route : Route
142 | , text : String
143 | }
144 | | Icon
145 | { route : Route
146 | , text : String
147 | , iconClass : String
148 | }
149 | | Image
150 | { route : Route
151 | , username : Username
152 | , imageUrl : Url
153 | }
154 | | Action
155 | { text : String
156 | , onClick : msg
157 | }
158 |
159 |
160 | guestNavLinks : Maybe GuestItem -> List ( Bool, NavLink msg )
161 | guestNavLinks maybeItem =
162 | [ ( maybeItem == Just GuestHome
163 | , Text { route = Route.Home, text = "Home" }
164 | )
165 | , ( maybeItem == Just Login
166 | , Text { route = Route.Login, text = "Sign in" }
167 | )
168 | , ( maybeItem == Just Register
169 | , Text { route = Route.Register, text = "Sign up" }
170 | )
171 | ]
172 |
173 |
174 | userNavLinks : Maybe UserItem -> UserDetails msg -> List ( Bool, NavLink msg )
175 | userNavLinks maybeItem { username, imageUrl, onLogout } =
176 | [ ( maybeItem == Just UserHome
177 | , Text { route = Route.Home, text = "Home" }
178 | )
179 | , ( maybeItem == Just NewArticle
180 | , Icon
181 | { route = Route.CreateArticle
182 | , text = "New Article"
183 | , iconClass = "ion-compose"
184 | }
185 | )
186 | , ( maybeItem == Just Settings
187 | , Icon
188 | { route = Route.Settings
189 | , text = "Settings"
190 | , iconClass = "ion-gear-a"
191 | }
192 | )
193 | , ( maybeItem == Just Profile
194 | , Image
195 | { route = Route.Profile username
196 | , username = username
197 | , imageUrl = imageUrl
198 | }
199 | )
200 | , ( False
201 | , Action { text = "Sign out", onClick = onLogout }
202 | )
203 | ]
204 |
205 |
206 | viewNavLink : Bool -> NavLink msg -> H.Html msg
207 | viewNavLink isActive navLink =
208 | let
209 | attrs route =
210 | [ HA.class "nav-link"
211 | , HA.classList [ ( "active", isActive ) ]
212 | , HA.href <| Route.toString route
213 | ]
214 | in
215 | case navLink of
216 | Text { route, text } ->
217 | H.a (attrs route)
218 | [ H.text text ]
219 |
220 | Icon { route, text, iconClass } ->
221 | H.a (attrs route)
222 | [ H.i [ HA.class iconClass ] []
223 | , H.text <| "\u{00A0}" ++ text
224 | ]
225 |
226 | Image { route, username, imageUrl } ->
227 | let
228 | text =
229 | Username.toString username
230 | in
231 | H.a (attrs route)
232 | [ H.img
233 | [ HA.class "user-pic"
234 | , HA.src <| Url.toString imageUrl
235 | , HA.alt text
236 | ]
237 | []
238 | , H.text text
239 | ]
240 |
241 | Action { text, onClick } ->
242 | H.a
243 | [ HA.class "nav-link"
244 | , HA.href Route.logoutPath
245 | , HE.preventDefaultOn "click" (JD.succeed ( onClick, True ))
246 | ]
247 | [ H.text text ]
248 |
--------------------------------------------------------------------------------
/src/Page/Settings.elm:
--------------------------------------------------------------------------------
1 | module Page.Settings exposing (InitOptions, Model, Msg, UpdateOptions, ViewOptions, init, update, view)
2 |
3 | import Api
4 | import Api.UpdateUser as UpdateUser
5 | import Browser as B
6 | import Data.Email as Email exposing (Email)
7 | import Data.Password exposing (Password)
8 | import Data.Token exposing (Token)
9 | import Data.User exposing (User)
10 | import Data.Username as Username exposing (Username)
11 | import Data.Validation as V
12 | import Lib.Browser.Dom as BD
13 | import Lib.Task as Task
14 | import Lib.Validation as V
15 | import Url exposing (Url)
16 | import View.Column as Column
17 | import View.Layout as Layout
18 | import View.Navigation as Navigation
19 | import View.Settings as Settings
20 |
21 |
22 |
23 | -- MODEL
24 |
25 |
26 | type alias Model =
27 | { imageUrl : String
28 | , username : String
29 | , bio : String
30 | , email : String
31 | , password : String
32 | , errorMessages : List String
33 | , isDisabled : Bool
34 | }
35 |
36 |
37 | type alias InitOptions msg =
38 | { imageUrl : Url
39 | , username : Username
40 | , bio : String
41 | , email : Email
42 | , onChange : Msg -> msg
43 | }
44 |
45 |
46 | init : InitOptions msg -> ( Model, Cmd msg )
47 | init options =
48 | ( initModel options
49 | , BD.focus "imageUrl" FocusedImageUrl
50 | |> Cmd.map options.onChange
51 | )
52 |
53 |
54 | initModel :
55 | { a
56 | | imageUrl : Url
57 | , username : Username
58 | , bio : String
59 | , email : Email
60 | }
61 | -> Model
62 | initModel { imageUrl, username, bio, email } =
63 | { imageUrl = Url.toString imageUrl
64 | , username = Username.toString username
65 | , bio = bio
66 | , email = Email.toString email
67 | , password = ""
68 | , errorMessages = []
69 | , isDisabled = False
70 | }
71 |
72 |
73 |
74 | -- UPDATE
75 |
76 |
77 | type alias UpdateOptions msg =
78 | { apiUrl : Url
79 | , token : Token
80 | , onUpdatedUser : User -> msg
81 | , onChange : Msg -> msg
82 | }
83 |
84 |
85 | type Msg
86 | = FocusedImageUrl
87 | | ChangedImageUrl String
88 | | ChangedUsername String
89 | | ChangedBio String
90 | | ChangedEmail String
91 | | ChangedPassword String
92 | | SubmittedForm
93 | | GotUpdateUserResponse (Result (Api.Error (List String)) User)
94 |
95 |
96 | update : UpdateOptions msg -> Msg -> Model -> ( Model, Cmd msg )
97 | update options msg model =
98 | case msg of
99 | FocusedImageUrl ->
100 | ( model, Cmd.none )
101 |
102 | ChangedImageUrl imageUrl ->
103 | ( { model | imageUrl = imageUrl }
104 | , Cmd.none
105 | )
106 |
107 | ChangedUsername username ->
108 | ( { model | username = username }
109 | , Cmd.none
110 | )
111 |
112 | ChangedBio bio ->
113 | ( { model | bio = bio }
114 | , Cmd.none
115 | )
116 |
117 | ChangedEmail email ->
118 | ( { model | email = email }
119 | , Cmd.none
120 | )
121 |
122 | ChangedPassword password ->
123 | ( { model | password = password }
124 | , Cmd.none
125 | )
126 |
127 | SubmittedForm ->
128 | validate model
129 | |> V.withValidation
130 | { onSuccess =
131 | \{ imageUrl, username, bio, email, maybePassword } ->
132 | ( { model | errorMessages = [], isDisabled = True }
133 | , UpdateUser.updateUser
134 | options.apiUrl
135 | { token = options.token
136 | , imageUrl = imageUrl
137 | , username = username
138 | , bio = bio
139 | , email = email
140 | , maybePassword = maybePassword
141 | , onResponse = GotUpdateUserResponse
142 | }
143 | |> Cmd.map options.onChange
144 | )
145 | , onFailure =
146 | \errorMessages ->
147 | ( { model | errorMessages = errorMessages }
148 | , Cmd.none
149 | )
150 | }
151 |
152 | GotUpdateUserResponse result ->
153 | Api.handleFormResponse
154 | (\user ->
155 | ( initModel user
156 | , Task.dispatch (options.onUpdatedUser user)
157 | )
158 | )
159 | model
160 | result
161 |
162 |
163 | type alias ValidatedFields =
164 | { imageUrl : Url
165 | , username : Username
166 | , bio : String
167 | , email : Email
168 | , maybePassword : Maybe Password
169 | }
170 |
171 |
172 | validate : Model -> V.Validation ValidatedFields
173 | validate { imageUrl, username, bio, email, password } =
174 | V.succeed ValidatedFields
175 | |> V.apply (V.imageUrl imageUrl)
176 | |> V.apply (V.username username)
177 | |> V.apply (V.bio bio)
178 | |> V.apply (V.email email)
179 | |> V.apply (V.optionalPassword password)
180 |
181 |
182 |
183 | -- VIEW
184 |
185 |
186 | type alias ViewOptions msg =
187 | { user : User
188 | , onLogout : msg
189 | , onChange : Msg -> msg
190 | }
191 |
192 |
193 | view : ViewOptions msg -> Model -> B.Document msg
194 | view { user, onLogout, onChange } { imageUrl, username, bio, email, password, errorMessages, isDisabled } =
195 | { title = "Settings"
196 | , body =
197 | [ Layout.view
198 | { name = "settings"
199 | , role =
200 | Navigation.settings
201 | { username = user.username
202 | , imageUrl = user.imageUrl
203 | , onLogout = onLogout
204 | }
205 | , maybeHeader = Nothing
206 | }
207 | [ Column.viewSingle Column.ExtraSmall
208 | [ Settings.view
209 | { form =
210 | { imageUrl = imageUrl
211 | , username = username
212 | , bio = bio
213 | , email = email
214 | , password = password
215 | , isDisabled = isDisabled
216 | , onInputImageUrl = onChange << ChangedImageUrl
217 | , onInputUsername = onChange << ChangedUsername
218 | , onInputBio = onChange << ChangedBio
219 | , onInputEmail = onChange << ChangedEmail
220 | , onInputPassword = onChange << ChangedPassword
221 | , onSubmit = onChange SubmittedForm
222 | }
223 | , errorMessages = errorMessages
224 | , onLogout = onLogout
225 | }
226 | ]
227 | ]
228 | ]
229 | }
230 |
--------------------------------------------------------------------------------
/prototype/article.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | How to build webapps that scale — Conduit
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
How to build webapps that scale
20 |
21 |
22 |

23 |
27 |
28 |
29 |
33 |
34 |
38 |
39 |
40 |
43 |
44 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | Web development technologies have evolved at an incredible clip over the past few years.
56 |
57 |
Introducing RealWorld.
58 |
It's a great solution for learning how other frameworks work.
59 |
60 | - realworld
61 | - implementations
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |

71 |
75 |
76 |
77 |
81 |
82 |
86 |
87 |
88 |
92 |
93 |
97 |
98 |
99 |
100 |
101 |
102 |
111 |
112 |
113 |
114 |
115 | With supporting text below as a natural lead-in to additional content.
116 |
117 |
118 |
126 |
127 |
128 |
129 |
130 |
131 | With supporting text below as a natural lead-in to additional content.
132 |
133 |
134 |
146 |
147 |
148 |
149 |
150 |
151 | With supporting text below as a natural lead-in to additional content.
152 |
153 |
154 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
--------------------------------------------------------------------------------