├── .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 |
30 |
31 | 32 |
33 |
34 | 35 |
36 | 37 |
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 |
30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 | 40 |
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 |
26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 | tag 40 |
41 |
42 | 45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Elm RealWorld Example App](/misc/elm-realworld-example-app.png) 2 | 3 | [![RealWorld Frontend](https://img.shields.io/badge/realworld-frontend-%23783578.svg)][RealWorld] 4 | [![Netlify Status](https://api.netlify.com/api/v1/badges/344f78b2-387c-4023-90ab-6cf07e8d8568/deploy-status)](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 |
17 |
18 |
19 |
20 |

Your Settings

21 | 22 |
    23 |
  • That name is required.
  • 24 |
25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 | 48 |
49 |
50 |
51 |
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 | 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 | 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 | 77 | 78 | 79 | 80 | 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 |
53 | 61 |
62 | 63 |
64 | 74 | 75 |

How to build webapps that scale

76 |

This is the description for the post.

77 | Read more... 78 |
    79 |
  • realworld
  • 80 |
  • implementations
  • 81 |
82 |
83 |
84 | 85 |
86 | 96 | 97 |

The song you won't ever stop singing. No matter how hard you try.

98 |

This is the description for the post.

99 | Read more... 100 |
    101 |
  • Music
  • 102 |
  • Song
  • 103 |
104 |
105 |
106 | 107 |
    108 |
  • 109 | 1 110 |
  • 111 |
  • 112 | 2 113 |
  • 114 |
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 | 25 | 26 |
27 | 33 | 34 |
35 |
36 |
37 |
38 | 46 |
47 | 48 |
49 | 59 | 60 |

How to build webapps that scale

61 |

This is the description for the post.

62 | Read more... 63 |
    64 |
  • realworld
  • 65 |
  • implementations
  • 66 |
67 |
68 |
69 | 70 |
71 | 81 | 82 |

The song you won't ever stop singing. No matter how hard you try.

83 |

This is the description for the post.

84 | Read more... 85 |
    86 |
  • realworld
  • 87 |
  • implementations
  • 88 |
89 |
90 |
91 | 92 |
    93 |
  • 94 | 1 95 |
  • 96 |
  • 97 | 2 98 |
  • 99 |
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 | 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 | 98 |
99 | 100 |
101 |
102 |
103 |
104 | 105 |
106 | 110 |
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 | --------------------------------------------------------------------------------