├── format-elm-files.sh
├── postcss.config.js
├── elm-analyse.json
├── src
├── elm
│ ├── UI
│ │ ├── Loader.elm
│ │ ├── Link.elm
│ │ ├── Toast.elm
│ │ ├── Icons.elm
│ │ └── Nav.elm
│ ├── Api
│ │ ├── Output.elm
│ │ └── Deserialize.elm
│ ├── Utils.elm
│ ├── Routes
│ │ ├── Site.elm
│ │ ├── Dash.elm
│ │ ├── Home.elm
│ │ ├── RegisterSite.elm
│ │ └── Home
│ │ │ └── Forms.elm
│ ├── SharedState.elm
│ ├── Main.elm
│ ├── Api.elm
│ └── Router.elm
├── index.html
├── index.js
└── css
│ └── custom.css
├── package.json
├── elm.json
├── README.md
├── webpack.config.js
└── .gitignore
/format-elm-files.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | elm-format --yes ./src/elm
4 |
5 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [
3 | require('postcss-import'),
4 | require('tailwindcss'),
5 | require('autoprefixer')
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/elm-analyse.json:
--------------------------------------------------------------------------------
1 | {
2 | "checks" : {
3 | "SingleFieldRecord": false,
4 | "ImportAll": false,
5 | "TriggerWords": false,
6 | "UseConsOverConcat": false
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/elm/UI/Loader.elm:
--------------------------------------------------------------------------------
1 | -- Exposes a loding UI component
2 |
3 |
4 | module UI.Loader exposing (donut)
5 |
6 | import Html exposing (Html, div)
7 | import Html.Attributes exposing (class)
8 |
9 |
10 | donut : Html msg
11 | donut =
12 | div [ class "loading__donut" ] []
13 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Parlez-Vous
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/elm/Api/Output.elm:
--------------------------------------------------------------------------------
1 | {-
2 | This module defines the data that will get sent
3 | to the API Server
4 | -}
5 |
6 |
7 | module Api.Output exposing
8 | ( RegisterSite
9 | , Signin
10 | , Signup
11 | )
12 |
13 |
14 | type alias Signup =
15 | { username : String
16 | , email : String
17 | , password : String
18 | , passwordConfirm : String
19 | }
20 |
21 |
22 | type alias Signin =
23 | { username : String
24 | , password : String
25 | }
26 |
27 |
28 | type alias RegisterSite =
29 | { hostname : String
30 | }
31 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('./css/custom.css')
4 |
5 | const Elm = require('./elm/Main.elm').Elm;
6 |
7 | const storagekey = '@pv/token'
8 |
9 | const api = process.env.API || 'https://staging-269700.appspot.com'
10 |
11 | const token = localStorage.getItem(storagekey)
12 |
13 | const app = Elm.Main.init({
14 | flags: {
15 | token,
16 | api
17 | },
18 | });
19 |
20 | app.ports.setToken.subscribe((t) => {
21 | localStorage.setItem(storagekey, t)
22 | })
23 |
24 | app.ports.removeToken.subscribe(() => {
25 | localStorage.removeItem(storagekey)
26 | })
27 |
--------------------------------------------------------------------------------
/src/css/custom.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 |
6 | /* Custom CSS Below
7 | *
8 | */
9 |
10 | html, body {
11 | height: 100%;
12 | }
13 |
14 | html > *, body > * {
15 | height: 100%;
16 | }
17 |
18 | @keyframes donut-spin {
19 | 0% {
20 | transform: rotate(0deg);
21 | }
22 |
23 | 100% {
24 | transform: rotate(360deg);
25 | }
26 | }
27 |
28 | .loading__donut {
29 | display: inline-block;
30 | border: 4px solid rgba(0, 0, 0, 0.1);
31 | border-left-color: #fa544b;
32 | border-radius: 50%;
33 | width: 30px;
34 | height: 30px;
35 | animation: donut-spin 1.2s linear infinite;
36 | }
37 |
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "parlez-vous-site",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "Main.elm",
6 | "scripts": {
7 | "start": "webpack-dev-server --port 3060",
8 | "build": "NODE_ENV=production webpack",
9 | "test": "echo \"Error: no test specified\" && exit 1"
10 | },
11 | "author": "Giorgio Delgado",
12 | "license": "UNLICENSED",
13 | "dependencies": {},
14 | "devDependencies": {
15 | "autoprefixer": "9.7.4",
16 | "css-loader": "2.1.0",
17 | "elm": "^0.19.1-5",
18 | "elm-format": "^0.8.5",
19 | "elm-webpack-loader": "6.0.0",
20 | "html-webpack-plugin": "3.2.0",
21 | "postcss-import": "^12.0.1",
22 | "postcss-loader": "3.0.0",
23 | "style-loader": "0.23.1",
24 | "tailwindcss": "^1.9.6",
25 | "webpack": "4.28.2",
26 | "webpack-cli": "3.1.2",
27 | "webpack-dev-server": "^3.11.2"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/elm.json:
--------------------------------------------------------------------------------
1 | {
2 | "type": "application",
3 | "source-directories": [
4 | "src/elm"
5 | ],
6 | "elm-version": "0.19.1",
7 | "dependencies": {
8 | "direct": {
9 | "elm/browser": "1.0.2",
10 | "elm/core": "1.0.5",
11 | "elm/html": "1.0.0",
12 | "elm/http": "2.0.0",
13 | "elm/json": "1.1.3",
14 | "elm/svg": "1.0.1",
15 | "elm/time": "1.0.0",
16 | "elm/url": "1.0.0",
17 | "krisajenkins/remotedata": "6.0.1",
18 | "pablen/toasty": "1.2.0",
19 | "panthershark/email-parser": "1.0.2",
20 | "supermacro/elm-antd": "7.2.0"
21 | },
22 | "indirect": {
23 | "avh4/elm-color": "1.0.0",
24 | "elm/bytes": "1.0.8",
25 | "elm/file": "1.0.5",
26 | "elm/parser": "1.1.0",
27 | "elm/random": "1.0.0",
28 | "elm/regex": "1.0.0",
29 | "elm/virtual-dom": "1.0.2",
30 | "elm-community/list-extra": "8.3.0",
31 | "fredcy/elm-parseint": "2.0.1",
32 | "noahzgordon/elm-color-extra": "1.0.2",
33 | "rtfeldman/elm-css": "16.1.1",
34 | "rtfeldman/elm-hex": "1.0.0"
35 | }
36 | },
37 | "test-dependencies": {
38 | "direct": {},
39 | "indirect": {}
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Parlez-Vous Landing Page
2 |
3 | > Work In Progress
4 |
5 | `master` maps to https://master--adoring-banach-15797c.netlify.com/ ... consider this the staging branch
6 |
7 |
8 | Need to connect this with the [server](https://github.com/parlez-vous/server)
9 |
10 |
11 |
12 | ### Optimizations
13 |
14 | https://discourse.elm-lang.org/t/optimize-elm-compilation-with-webpack/2191/8
15 |
16 |
17 |
18 | ## Architectural Inspiration
19 |
20 | - [elm-shared-state](https://github.com/ohanhi/elm-shared-state)
21 | - This, I would say, is the app from which I took the most inspiration from. I didn't even change the type names around shared state.
22 |
23 | - [elm-shared-login](https://github.com/jxxcarlson/elm-shared-login)
24 | - Same idea
25 |
26 | - [elm-spa-example](https://github.com/rtfeldman/elm-spa-example)
27 |
28 |
29 | ## Contributing
30 |
31 | ### Adding a new page
32 |
33 | - Add a new `.elm` file to the `Routes` folder. This file will represent the page.
34 | - Connect the `Model` and the `Msg` within the `Router.elm` file
35 | - Update the `parser` which will take care of url handling
36 | - Update the `update` function so that it's aware of events that will occur within the new page
37 | - Update the `view` function so that it renders your page!
38 | - Update the `transitionTrigger` function so that any "on-load" events get fired within your new page
39 |
40 |
--------------------------------------------------------------------------------
/src/elm/Utils.elm:
--------------------------------------------------------------------------------
1 | port module Utils exposing
2 | ( getApi
3 | , getNavKey
4 | , logout
5 | , setToken
6 | , toClass
7 | )
8 |
9 | import Api exposing (Api)
10 | import Browser.Navigation as Nav
11 | import Html exposing (Attribute)
12 | import Html.Attributes exposing (class)
13 | import SharedState exposing (PrivateState, SharedState(..), SharedStateUpdate(..))
14 |
15 |
16 | port removeToken : () -> Cmd msg
17 |
18 |
19 | port setToken : String -> Cmd msg
20 |
21 |
22 |
23 | -- TODO: turn into extensible record
24 |
25 |
26 | getApi : SharedState -> Api
27 | getApi sharedState =
28 | case sharedState of
29 | Public { api } ->
30 | api
31 |
32 | Private { api } ->
33 | api
34 |
35 |
36 | getNavKey : SharedState -> Nav.Key
37 | getNavKey sharedState =
38 | case sharedState of
39 | Public { navKey } ->
40 | navKey
41 |
42 | Private { navKey } ->
43 | navKey
44 |
45 |
46 | logout : PrivateState -> ( Cmd msg, SharedStateUpdate )
47 | logout { navKey, api } =
48 | let
49 | publicState =
50 | { navKey = navKey
51 | , api = api
52 | }
53 | in
54 | ( removeToken ()
55 | , SharedState.LogOut publicState
56 | )
57 |
58 |
59 | toClass : List String -> Attribute msg
60 | toClass list =
61 | List.intersperse " " list
62 | |> String.concat
63 | |> class
64 |
--------------------------------------------------------------------------------
/src/elm/UI/Link.elm:
--------------------------------------------------------------------------------
1 | module UI.Link exposing
2 | ( Route(..)
3 | , externalLink
4 | , link
5 | , toHref
6 | , toHtml
7 | )
8 |
9 | import Html exposing (..)
10 | import Html.Attributes exposing (..)
11 | import Utils as Utils
12 |
13 |
14 | type Route
15 | = Home
16 | | Dash
17 | | Site String
18 | | Login
19 | | Signup
20 | | RegisterSite
21 |
22 |
23 | type alias Options =
24 | { route : Route
25 | }
26 |
27 |
28 | type alias Label =
29 | String
30 |
31 |
32 | type Link
33 | = Link Options Label
34 |
35 |
36 | link : Route -> Label -> Link
37 | link route =
38 | Link (Options route)
39 |
40 |
41 | toHref : Route -> String
42 | toHref route =
43 | case route of
44 | Home ->
45 | "/"
46 |
47 | Dash ->
48 | "/dash"
49 |
50 | Site id ->
51 | "/sites/" ++ id
52 |
53 | Login ->
54 | "/login"
55 |
56 | Signup ->
57 | "/signup"
58 |
59 | RegisterSite ->
60 | "/register-site"
61 |
62 |
63 | linkStyles : List String
64 | linkStyles =
65 | [ "text-blue-500"
66 | , "underline"
67 | , "hover:text-blue-700"
68 | ]
69 |
70 |
71 | toHtml : Link -> Html msg
72 | toHtml (Link opts label) =
73 | a [ href (toHref opts.route), Utils.toClass linkStyles ] [ text label ]
74 |
75 |
76 | type alias URL =
77 | String
78 |
79 |
80 | externalLink : URL -> String -> Html a
81 | externalLink url label =
82 | a [ href url, Utils.toClass linkStyles ] [ text label ]
83 |
--------------------------------------------------------------------------------
/src/elm/Api/Deserialize.elm:
--------------------------------------------------------------------------------
1 | module Api.Deserialize exposing
2 | ( Admin
3 | , AdminWithToken
4 | , SessionToken
5 | , Site
6 | , Sites
7 | , adminAndTokenDecoder
8 | , adminDecoder
9 | , siteDecoder
10 | )
11 |
12 | import Json.Decode as D exposing (Decoder)
13 | import Time
14 |
15 |
16 |
17 | -- TODO: convert into opaque type
18 |
19 |
20 | type alias SessionToken =
21 | String
22 |
23 |
24 | type alias Admin =
25 | { id : String
26 | , username : String
27 | , created : Time.Posix
28 | , updated : Time.Posix
29 | }
30 |
31 |
32 | type alias Site =
33 | { id : String
34 | , hostname : String
35 | , created : Time.Posix
36 | , updated : Time.Posix
37 | }
38 |
39 |
40 | type alias Sites =
41 | List Site
42 |
43 |
44 | type alias AdminWithToken =
45 | ( Admin, SessionToken )
46 |
47 |
48 | adminDecoder : Decoder Admin
49 | adminDecoder =
50 | D.map4 Admin
51 | (D.field "id" D.string)
52 | (D.field "username" D.string)
53 | (D.field "createdAt" posixTimeDecoder)
54 | (D.field "updatedAt" posixTimeDecoder)
55 |
56 |
57 | adminAndTokenDecoder : Decoder ( Admin, SessionToken )
58 | adminAndTokenDecoder =
59 | D.map2 Tuple.pair
60 | (D.field "data" adminDecoder)
61 | (D.field "sessionToken" D.string)
62 |
63 |
64 | posixTimeDecoder : Decoder Time.Posix
65 | posixTimeDecoder =
66 | D.int
67 | |> D.map Time.millisToPosix
68 |
69 |
70 | siteDecoder : Decoder Site
71 | siteDecoder =
72 | D.map4 Site
73 | (D.field "id" D.string)
74 | (D.field "hostname" D.string)
75 | (D.field "createdAt" posixTimeDecoder)
76 | (D.field "updatedAt" posixTimeDecoder)
77 |
--------------------------------------------------------------------------------
/src/elm/Routes/Site.elm:
--------------------------------------------------------------------------------
1 | module Routes.Site exposing
2 | ( Model
3 | , Msg
4 | , initModel
5 | , update
6 | , view
7 | )
8 |
9 | import Dict
10 | import Html exposing (..)
11 | import Html.Attributes exposing (class)
12 | import RemoteData
13 | import SharedState exposing (PrivateState, SharedStateUpdate)
14 | import UI.Nav as ResponsiveNav exposing (withVnav)
15 |
16 |
17 | type alias Model =
18 | { siteId : String
19 | , navbar : ResponsiveNav.NavState
20 | }
21 |
22 |
23 | type Msg
24 | = ResponsiveNavMsg ResponsiveNav.Msg
25 |
26 |
27 | initModel : String -> Model
28 | initModel siteId =
29 | Model siteId ResponsiveNav.init
30 |
31 |
32 | update : PrivateState -> Msg -> Model -> ( Model, Cmd Msg, SharedStateUpdate )
33 | update _ msg model =
34 | case msg of
35 | ResponsiveNavMsg subMsg ->
36 | ResponsiveNav.update subMsg model
37 |
38 |
39 | type alias Title =
40 | String
41 |
42 |
43 | view : PrivateState -> Model -> ( Title, Html Msg )
44 | view state model =
45 | let
46 | viewWithNav =
47 | withVnav state model ResponsiveNavMsg
48 |
49 | maybeSite =
50 | case state.sites of
51 | RemoteData.Success sites_ ->
52 | Dict.get model.siteId sites_
53 |
54 | _ ->
55 | Nothing
56 |
57 | info =
58 | case maybeSite of
59 | Nothing ->
60 | text "site not found"
61 |
62 | Just site ->
63 | div [ class "m-2 md:m-12 w-full" ]
64 | [ h1 [ class "text-2xl" ] [ text site.hostname ]
65 | , div [] [ text "ayyy you all good!" ]
66 | ]
67 | in
68 | ( "Site"
69 | , viewWithNav info
70 | )
71 |
--------------------------------------------------------------------------------
/src/elm/UI/Toast.elm:
--------------------------------------------------------------------------------
1 | module UI.Toast exposing
2 | ( ToastMsg
3 | , ToastState
4 | , addToast
5 | , init
6 | , update
7 | , view
8 | )
9 |
10 | import Html exposing (Html, div, text)
11 | import Html.Attributes exposing (class)
12 | import Toasty exposing (Stack)
13 | import Utils
14 |
15 |
16 | type alias ToastState =
17 | Stack String
18 |
19 |
20 | type alias ToastMsg =
21 | Toasty.Msg String
22 |
23 |
24 | type alias WithToasts a =
25 | { a
26 | | toasts : ToastState
27 | }
28 |
29 |
30 | init : ToastState
31 | init =
32 | Toasty.initialState
33 |
34 |
35 | config : Toasty.Config msg
36 | config =
37 | Toasty.config
38 |
39 |
40 | addToast : (Toasty.Msg String -> a) -> String -> ( WithToasts b, Cmd a ) -> ( WithToasts b, Cmd a )
41 | addToast tagger toast ( model, cmd ) =
42 | let
43 | toasties_ =
44 | { toasties = model.toasts }
45 |
46 | ( { toasties }, newCmd ) =
47 | Toasty.addToast config tagger toast ( toasties_, cmd )
48 | in
49 | ( { model | toasts = toasties }
50 | , newCmd
51 | )
52 |
53 |
54 | update : (Toasty.Msg String -> a) -> Toasty.Msg String -> WithToasts b -> ( WithToasts b, Cmd a )
55 | update tagger toastInfo model =
56 | let
57 | toasties_ =
58 | { toasties = model.toasts
59 | }
60 |
61 | ( { toasties }, cmd ) =
62 | Toasty.update config tagger toastInfo toasties_
63 | in
64 | ( { model | toasts = toasties }
65 | , cmd
66 | )
67 |
68 |
69 |
70 | -- TODO: customize inset to provide some space
71 | -- between the bottom and the toast on wider
72 |
73 |
74 | view : (Toasty.Msg String -> msg) -> ToastState -> Html msg
75 | view toMsg toastStack =
76 | div [ class "fixed inset-x-0 bottom-0" ]
77 | [ Toasty.view config renderToast toMsg toastStack ]
78 |
79 |
80 | renderToast : String -> Html msg
81 | renderToast toast =
82 | let
83 | classes =
84 | Utils.toClass
85 | [ "py-1"
86 | , "px-2"
87 | , "bg-red-200"
88 | , "text-red-700"
89 | , "rounded"
90 | , "mb-4"
91 | , "md:mx-auto"
92 | , "md:w-1/6"
93 | ]
94 | in
95 | div [ classes ] [ text toast ]
96 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack')
2 | const path = require("path");
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 |
5 | const SOURCE_DIR = path.join(__dirname, 'src')
6 |
7 | // Copy the specified environment variables into an object we can pass to
8 | // webpack's DefinePlugin
9 | const copyArgs = (args) =>
10 | args.reduce(
11 | (acc, key) => ({
12 | // Create an object with the specified key
13 | ...acc,
14 | [`process.env.${key}`]: JSON.stringify(process.env[key]),
15 | }),
16 | {}
17 | )
18 |
19 |
20 | const IS_PROD = process.env.NODE_ENV === 'production'
21 | const IS_DEVELOPMENT = !IS_PROD
22 |
23 |
24 | const commonConfig = {
25 | mode: IS_PROD
26 | ? 'production'
27 | : 'development',
28 |
29 | entry: {
30 | app: [
31 | './src/index.js'
32 | ]
33 | },
34 |
35 | output: {
36 | path: path.resolve(__dirname + '/dist'),
37 | filename: '[name].js',
38 | publicPath: '/',
39 | },
40 |
41 | module: {
42 | rules: [
43 | {
44 | test: /\.css$/,
45 | use: [
46 | 'style-loader',
47 | 'css-loader',
48 | 'postcss-loader'
49 | ],
50 | },
51 | {
52 | test: /\.elm$/,
53 | exclude: [/elm-stuff/, /node_modules/],
54 | loader: 'elm-webpack-loader',
55 | options: {
56 | cache: false,
57 | // turns on the time-travelling debugger
58 | // this is a flag that is passed to elm make
59 | debug: IS_DEVELOPMENT,
60 | optimize: IS_PROD,
61 | }
62 | },
63 | ],
64 |
65 | noParse: /\.elm$/,
66 | },
67 |
68 | plugins: [
69 | new HtmlWebpackPlugin({
70 | template: path.join(SOURCE_DIR, 'index.html'),
71 | minify: {
72 | collapseWhitespace: true,
73 | removeComments: true
74 | }
75 | // favicon: path.resolve('./static/favicon.png')
76 | }),
77 | new webpack.DefinePlugin({
78 | ...copyArgs([
79 | 'NODE_ENV',
80 | 'API',
81 | ]),
82 | })
83 | ],
84 | }
85 |
86 | const developmentConfig = {
87 | devServer: {
88 | inline: true,
89 | stats: { colors: true },
90 | historyApiFallback: true
91 | },
92 | }
93 |
94 |
95 | module.exports = IS_DEVELOPMENT
96 | ? { ...commonConfig, ...developmentConfig }
97 | : commonConfig;
98 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | elm-stuff/
2 |
3 | # Created by https://www.gitignore.io/api/node,macos
4 | # Edit at https://www.gitignore.io/?templates=node,macos
5 |
6 | .vscode/settings.json
7 |
8 | .idea
9 |
10 | dist/
11 |
12 | ### macOS ###
13 | # General
14 | .DS_Store
15 | .AppleDouble
16 | .LSOverride
17 |
18 | # Icon must end with two \r
19 | Icon
20 |
21 | # Thumbnails
22 | ._*
23 |
24 | # Files that might appear in the root of a volume
25 | .DocumentRevisions-V100
26 | .fseventsd
27 | .Spotlight-V100
28 | .TemporaryItems
29 | .Trashes
30 | .VolumeIcon.icns
31 | .com.apple.timemachine.donotpresent
32 |
33 | # Directories potentially created on remote AFP share
34 | .AppleDB
35 | .AppleDesktop
36 | Network Trash Folder
37 | Temporary Items
38 | .apdisk
39 |
40 | ### Node ###
41 | # Logs
42 | logs
43 | *.log
44 | npm-debug.log*
45 | yarn-debug.log*
46 | yarn-error.log*
47 |
48 | # Runtime data
49 | pids
50 | *.pid
51 | *.seed
52 | *.pid.lock
53 |
54 | # Directory for instrumented libs generated by jscoverage/JSCover
55 | lib-cov
56 |
57 | # Coverage directory used by tools like istanbul
58 | coverage
59 |
60 | # nyc test coverage
61 | .nyc_output
62 |
63 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
64 | .grunt
65 |
66 | # Bower dependency directory (https://bower.io/)
67 | bower_components
68 |
69 | # node-waf configuration
70 | .lock-wscript
71 |
72 | # Compiled binary addons (https://nodejs.org/api/addons.html)
73 | build/Release
74 |
75 | # Dependency directories
76 | node_modules/
77 | jspm_packages/
78 |
79 | # TypeScript v1 declaration files
80 | typings/
81 |
82 | # Optional npm cache directory
83 | .npm
84 |
85 | # Optional eslint cache
86 | .eslintcache
87 |
88 | # Optional REPL history
89 | .node_repl_history
90 |
91 | # Output of 'npm pack'
92 | *.tgz
93 |
94 | # Yarn Integrity file
95 | .yarn-integrity
96 |
97 | # dotenv environment variables file
98 | .env
99 | .env.test
100 |
101 | # parcel-bundler cache (https://parceljs.org/)
102 | .cache
103 |
104 | # next.js build output
105 | .next
106 |
107 | # nuxt.js build output
108 | .nuxt
109 |
110 | # vuepress build output
111 | .vuepress/dist
112 |
113 | # Serverless directories
114 | .serverless/
115 |
116 | # FuseBox cache
117 | .fusebox/
118 |
119 | # DynamoDB Local files
120 | .dynamodb/
121 |
122 | # End of https://www.gitignore.io/api/node,macos
123 |
--------------------------------------------------------------------------------
/src/elm/Routes/Dash.elm:
--------------------------------------------------------------------------------
1 | module Routes.Dash exposing
2 | ( Model
3 | , Msg
4 | , initModel
5 | , update
6 | , view
7 | )
8 |
9 | import Dict
10 | import Html exposing (..)
11 | import Html.Attributes exposing (..)
12 | import RemoteData
13 | import SharedState exposing (PrivateState, SharedStateUpdate(..), SiteDict)
14 | import UI.Link as Link
15 | import UI.Loader as Loader
16 | import UI.Nav as ResponsiveNav exposing (withVnav)
17 | import UI.Toast as Toast
18 |
19 |
20 |
21 | -- MODEL
22 |
23 |
24 | type alias Model =
25 | { toasts : Toast.ToastState
26 | , navbar : ResponsiveNav.NavState
27 | }
28 |
29 |
30 | type Msg
31 | = ToastMsg Toast.ToastMsg
32 | | ResponsiveNavMsg ResponsiveNav.Msg
33 |
34 |
35 | initModel : Model
36 | initModel =
37 | { toasts = Toast.init
38 | , navbar = ResponsiveNav.init
39 | }
40 |
41 |
42 | update : PrivateState -> Msg -> Model -> ( Model, Cmd Msg, SharedStateUpdate )
43 | update _ msg model =
44 | case msg of
45 | -- this gets triggered __some__time__
46 | -- after a toast gets added to the stack
47 | -- via `addToast`
48 | ToastMsg subMsg ->
49 | let
50 | ( m, cmd ) =
51 | model
52 | |> Toast.update ToastMsg subMsg
53 | in
54 | ( m
55 | , cmd
56 | , NoUpdate
57 | )
58 |
59 | ResponsiveNavMsg subMsg ->
60 | ResponsiveNav.update subMsg model
61 |
62 |
63 | viewDash : SiteDict -> Html Msg
64 | viewDash sites =
65 | let
66 | registerSiteLink =
67 | Link.link Link.RegisterSite "registering"
68 | |> Link.toHtml
69 |
70 | content =
71 | if Dict.isEmpty sites then
72 | [ text "hmm... it's awefully quite around here ... start by "
73 | , registerSiteLink
74 | , text " your site!"
75 | ]
76 |
77 | else
78 | [ text "look at you go!" ]
79 | in
80 | div [] content
81 |
82 |
83 | type alias Title =
84 | String
85 |
86 |
87 | view : PrivateState -> Model -> ( Title, Html Msg )
88 | view state model =
89 | let
90 | welcomeHeader =
91 | h1 [] [ text "Welcome!" ]
92 |
93 | content =
94 | case state.sites of
95 | RemoteData.NotAsked ->
96 | Loader.donut
97 |
98 | RemoteData.Loading ->
99 | Loader.donut
100 |
101 | RemoteData.Success sites ->
102 | viewDash sites
103 |
104 | RemoteData.Failure _ ->
105 | div [] [ text "woopsies!" ]
106 |
107 | viewWithNav =
108 | withVnav state model ResponsiveNavMsg
109 |
110 | html =
111 | viewWithNav
112 | (div [ class "my-5 mx-8" ]
113 | [ welcomeHeader
114 | , content
115 | , Toast.view ToastMsg model.toasts
116 | ]
117 | )
118 | in
119 | ( "Admin Panel", html )
120 |
--------------------------------------------------------------------------------
/src/elm/SharedState.elm:
--------------------------------------------------------------------------------
1 | module SharedState exposing
2 | ( PrivateState
3 | , PublicState
4 | , SharedState(..)
5 | , SharedStateUpdate(..)
6 | , SiteDict
7 | , init
8 | , toDict
9 | , toPrivate
10 | , update
11 | )
12 |
13 | import Api exposing (Api)
14 | import Api.Deserialize as Input
15 | import Browser.Navigation
16 | import Dict exposing (Dict)
17 | import RemoteData exposing (WebData)
18 |
19 |
20 | type alias UUID =
21 | String
22 |
23 |
24 | type alias SiteDict =
25 | Dict UUID Input.Site
26 |
27 |
28 | toDict : Input.Sites -> SiteDict
29 | toDict =
30 | List.foldl (\site -> Dict.insert site.id site) Dict.empty
31 |
32 |
33 | type SharedStateUpdate
34 | = NoUpdate
35 | | SetAdmin Input.AdminWithToken
36 | | UpdateSites SiteDict
37 | | InsertSite Input.Site
38 | | LogOut PublicState
39 |
40 |
41 | type alias BaseState a =
42 | { a
43 | | navKey : Browser.Navigation.Key
44 | , api : Api
45 | }
46 |
47 |
48 | type alias PublicState =
49 | BaseState {}
50 |
51 |
52 | type alias PrivateState =
53 | BaseState
54 | { admin : Input.AdminWithToken
55 | , sites : WebData SiteDict
56 | }
57 |
58 |
59 | type SharedState
60 | = Public PublicState
61 | | Private PrivateState
62 |
63 |
64 | toPrivate : Input.AdminWithToken -> PublicState -> PrivateState
65 | toPrivate admin publicState =
66 | { navKey = publicState.navKey
67 | , api = publicState.api
68 | , admin = admin
69 | , sites = RemoteData.NotAsked
70 | }
71 |
72 |
73 | init : Browser.Navigation.Key -> Api -> PublicState
74 | init key api =
75 | { navKey = key
76 | , api = api
77 | }
78 |
79 |
80 | update : SharedStateUpdate -> SharedState -> SharedState
81 | update updateMsg state =
82 | case updateMsg of
83 | -- called when user logs in
84 | SetAdmin admin ->
85 | case state of
86 | Public { navKey, api } ->
87 | Private
88 | { navKey = navKey
89 | , api = api
90 | , admin = admin
91 | , sites = RemoteData.NotAsked
92 | }
93 |
94 | Private _ ->
95 | state
96 |
97 | UpdateSites sites ->
98 | case state of
99 | Public _ ->
100 | state
101 |
102 | Private privateState ->
103 | Private
104 | { privateState
105 | | sites = RemoteData.Success sites
106 | }
107 |
108 | InsertSite site ->
109 | case state of
110 | Public _ ->
111 | state
112 |
113 | Private privateState ->
114 | let
115 | sites =
116 | case privateState.sites of
117 | RemoteData.Success sites_ ->
118 | sites_
119 |
120 | _ ->
121 | Dict.empty
122 | in
123 | Private
124 | { privateState
125 | | sites = RemoteData.Success <| Dict.insert site.id site sites
126 | }
127 |
128 | LogOut publicState ->
129 | Public publicState
130 |
131 | NoUpdate ->
132 | state
133 |
--------------------------------------------------------------------------------
/src/elm/UI/Icons.elm:
--------------------------------------------------------------------------------
1 | module UI.Icons exposing
2 | ( cog
3 | , hamburger
4 | , logo
5 | , rightCaret
6 | , x
7 | )
8 |
9 | import Html exposing (Html)
10 | import Svg exposing (..)
11 | import Svg.Attributes exposing (..)
12 |
13 |
14 | logo : String -> Html msg
15 | logo width_ =
16 | svg
17 | [ version "1.1"
18 | , width width_
19 | , viewBox "0 0 142.0155029296875 131.010986328125"
20 | ]
21 | [ g []
22 | [ svg
23 | [ width "142.0155029296875"
24 | , height "131.010986328125"
25 | , viewBox "28.88103485107422 34.11328887939453 142.0155029296875 131.010986328125"
26 | ]
27 | [ g [] [ Svg.path [ d "M109.63 36.08c4.115 1.547 11.475 6.681 11.3 22.42-.333 30.35-4.893 43.653-21.648 60.353-6.323 6.3-24.225 13.765-39.662 10.346-43.273-9.582-34.589-54.357-12.185-57.9 17.631-2.788 24.256-7.629 31.154-16.251C92.428 37.751 94.022 30.211 109.63 36.08z", fill "#fa544b" ] [], Svg.path [ d "M159.638 49.875c-4.876-1.625-25.013-2.308-40.113 8.1C90.407 78.062 75.611 95.761 62.3 122.034c-4.606 9.09-9.983 21.491-2.029 32.626 4.018 5.626 16.725 9.157 20.893 9.643 48.215 5.625 68.255-18.922 60.5-34.714l-1.989-4.052c-7.629-13.582-.687-24.969 4.442-30.826 16.862-19.252 41.388-36.211 15.521-44.836z", fill "#b7b7b7" ] [], Svg.path [ d "M62.3 122.034q-1.733 3.42-3.248 7.02c.2.045.373.1.57.145 15.437 3.419 33.339-4.044 39.662-10.346 16.755-16.7 21.315-30 21.648-60.353.006-.485-.017-.928-.026-1.393-.46.292-.928.562-1.38.873C90.407 78.062 75.611 95.761 62.3 122.034z", fill "#504d49" ] [] ] ]
28 | ]
29 | ]
30 |
31 |
32 | rightCaret : Html msg
33 | rightCaret =
34 | svg
35 | [ class "fill-current text-teal-500 h-6 inline-block"
36 | , viewBox "0 0 20 20"
37 | ]
38 | [ Svg.path [ d "M11.611 10.049l-4.76-4.873a.783.783 0 011.117-1.093l5.306 5.433a.78.78 0 01-.012 1.105L7.83 15.928a.784.784 0 01-1.106-.013.78.78 0 01.012-1.104l4.875-4.762z" ] [] ]
39 |
40 |
41 | x : Html msg
42 | x =
43 | svg
44 | [ class "fill-current h-8"
45 | , viewBox "0 0 20 20"
46 | ]
47 | [ Svg.path [ d "M15.898 4.045a.698.698 0 00-.986 0l-4.71 4.711-4.709-4.711a.698.698 0 00-.986.986l4.709 4.711-4.71 4.711a.698.698 0 00.985.986l4.711-4.711 4.71 4.711a.695.695 0 00.494.203.694.694 0 00.492-1.189l-4.711-4.711 4.711-4.711a.694.694 0 000-.986z" ] [] ]
48 |
49 |
50 | hamburger : Html msg
51 | hamburger =
52 | svg
53 | [ class "fill-current h-4"
54 | , viewBox "0 0 20 20"
55 | ]
56 | [ Svg.path [ d "M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z" ] [] ]
57 |
58 |
59 | cog : Html msg
60 | cog =
61 | svg
62 | [ class "fill-current h-8 inline-block"
63 | , viewBox "0 0 24 24"
64 | ]
65 | [ Svg.path
66 | [ fill "#fa544b"
67 | , d "M11.701 16.7a5.002 5.002 0 1 1 0-10.003 5.002 5.002 0 0 1 0 10.004m8.368-3.117a1.995 1.995 0 0 1-1.346-1.885c0-.876.563-1.613 1.345-1.885a.48.48 0 0 0 .315-.574 8.947 8.947 0 0 0-.836-1.993.477.477 0 0 0-.598-.195 2.04 2.04 0 0 1-1.29.08 1.988 1.988 0 0 1-1.404-1.395 2.04 2.04 0 0 1 .076-1.297.478.478 0 0 0-.196-.597 8.98 8.98 0 0 0-1.975-.826.479.479 0 0 0-.574.314 1.995 1.995 0 0 1-1.885 1.346 1.994 1.994 0 0 1-1.884-1.345.482.482 0 0 0-.575-.315c-.708.2-1.379.485-2.004.842a.47.47 0 0 0-.198.582A2.002 2.002 0 0 1 4.445 7.06a.478.478 0 0 0-.595.196 8.946 8.946 0 0 0-.833 1.994.48.48 0 0 0 .308.572 1.995 1.995 0 0 1 1.323 1.877c0 .867-.552 1.599-1.324 1.877a.479.479 0 0 0-.308.57 8.99 8.99 0 0 0 .723 1.79.477.477 0 0 0 .624.194c.595-.273 1.343-.264 2.104.238.117.077.225.185.302.3.527.8.512 1.58.198 2.188a.473.473 0 0 0 .168.628 8.946 8.946 0 0 0 2.11.897.474.474 0 0 0 .57-.313 1.995 1.995 0 0 1 1.886-1.353c.878 0 1.618.567 1.887 1.353a.475.475 0 0 0 .57.313 8.964 8.964 0 0 0 2.084-.883.473.473 0 0 0 .167-.631c-.318-.608-.337-1.393.191-2.195.077-.116.185-.225.302-.302.772-.511 1.527-.513 2.125-.23a.477.477 0 0 0 .628-.19 8.925 8.925 0 0 0 .728-1.793.478.478 0 0 0-.314-.573"
68 | , fillRule "evenodd"
69 | ]
70 | []
71 | ]
72 |
--------------------------------------------------------------------------------
/src/elm/UI/Nav.elm:
--------------------------------------------------------------------------------
1 | {--
2 | Horizontal Nav
3 | --}
4 |
5 |
6 | module UI.Nav exposing
7 | ( Msg
8 | , NavState
9 | , init
10 | , update
11 | , withHnav
12 | , withVnav
13 | )
14 |
15 | import Ant.Button as Btn exposing (button)
16 | import Ant.Typography.Text as Text
17 | import Browser.Navigation as Nav
18 | import Dict
19 | import Html exposing (Html, a, div, header, nav)
20 | import Html.Attributes exposing (class)
21 | import Html.Events exposing (onClick)
22 | import RemoteData
23 | import SharedState exposing (PrivateState, SharedStateUpdate)
24 | import UI.Icons exposing (hamburger, logo, x)
25 | import UI.Link as Link
26 | import UI.Loader as Loader
27 | import Utils as Utils
28 |
29 |
30 | type alias NavState =
31 | { responsiveNavVisible : Bool
32 | , siteSummaryVisible : Bool
33 | }
34 |
35 |
36 | type alias WithNavbar a =
37 | { a
38 | | navbar : NavState
39 | }
40 |
41 |
42 | type Msg
43 | = LogOut PrivateState
44 | | ToggleResponsiveNavbar
45 | | RegisterNewSite PrivateState
46 | | GoToHome PrivateState
47 |
48 |
49 | init : NavState
50 | init =
51 | { responsiveNavVisible = True
52 | , siteSummaryVisible = False
53 | }
54 |
55 |
56 | update : Msg -> WithNavbar a -> ( WithNavbar a, Cmd msg, SharedStateUpdate )
57 | update msg ({ navbar } as parentModel) =
58 | case msg of
59 | ToggleResponsiveNavbar ->
60 | ( { parentModel
61 | | navbar =
62 | { navbar
63 | | responsiveNavVisible = not navbar.responsiveNavVisible
64 | }
65 | }
66 | , Cmd.none
67 | , SharedState.NoUpdate
68 | )
69 |
70 | GoToHome state ->
71 | ( parentModel
72 | , Link.toHref Link.Home |> Nav.pushUrl state.navKey
73 | , SharedState.NoUpdate
74 | )
75 |
76 | RegisterNewSite state ->
77 | ( parentModel
78 | , Link.toHref Link.RegisterSite |> Nav.pushUrl state.navKey
79 | , SharedState.NoUpdate
80 | )
81 |
82 | LogOut privateState ->
83 | let
84 | ( logOutCmd, sharedStateUpdate ) =
85 | Utils.logout privateState
86 | in
87 | ( parentModel
88 | , Cmd.batch [ logOutCmd, Nav.pushUrl privateState.navKey "/" ]
89 | , sharedStateUpdate
90 | )
91 |
92 |
93 | hnav : List (Html msg) -> Html msg
94 | hnav children =
95 | header [ class "w-auto h-20 pt-6 pb-2 px-4 flex justify-between" ]
96 | [ logo "45"
97 | , nav [] children
98 | ]
99 |
100 |
101 | withVnav : PrivateState -> { a | navbar : NavState } -> (Msg -> msg) -> Html msg -> Html msg
102 | withVnav state { navbar } tagger pageContent =
103 | let
104 | loading =
105 | div [ class "loading-container" ] [ Loader.donut ]
106 |
107 | siteNav =
108 | case state.sites of
109 | RemoteData.Success sites ->
110 | Dict.values sites
111 | |> List.map
112 | (\site ->
113 | div []
114 | [ Text.text site.hostname
115 | |> Text.withType (Text.Link (Link.toHref <| Link.Site site.id) Text.Self)
116 | |> Text.toHtml
117 | ]
118 | )
119 | |> div []
120 |
121 | _ ->
122 | loading
123 |
124 | navTopContents =
125 | [ Html.button [ onClick (tagger <| GoToHome state) ]
126 | [ logo "40"
127 | ]
128 | , siteNav
129 | ]
130 |
131 | navBottomContents =
132 | [ button "Log Out"
133 | |> Btn.onClick (tagger <| LogOut state)
134 | |> Btn.toHtml
135 | , button
136 | "New site"
137 | |> Btn.onClick (tagger <| RegisterNewSite state)
138 | |> Btn.toHtml
139 | ]
140 |
141 | navContent =
142 | div [ class "font-bold flex flex-col items-center h-full" ]
143 | [ div [ class "flex flex-col justify-between h-full" ]
144 | [ div [] navTopContents
145 | , div [] navBottomContents
146 | ]
147 | ]
148 |
149 | regularNav =
150 | header
151 | [ class "hidden bg-gray-200 h-full w-48 p-5 md:block"
152 | ]
153 | [ navContent ]
154 |
155 | closeIcon =
156 | div [ onClick (tagger ToggleResponsiveNavbar) ]
157 | [ x ]
158 |
159 | responsiveNav =
160 | if navbar.responsiveNavVisible then
161 | header
162 | [ class "bg-gray-100 fixed h-full w-2/3 p-5 md:hidden"
163 | ]
164 | [ closeIcon, navContent ]
165 |
166 | else
167 | div
168 | [ class "m-5 flex justify-between md:hidden"
169 | , onClick (tagger ToggleResponsiveNavbar)
170 | ]
171 | [ hamburger ]
172 | in
173 | div [ class "h-full w-full md:flex" ]
174 | [ responsiveNav
175 | , regularNav
176 | , pageContent
177 | ]
178 |
179 |
180 | withHnav : List (Html msg) -> List (Html msg) -> Html msg
181 | withHnav items content =
182 | div [ class "h-full w-full" ]
183 | [ hnav items
184 | , div [ class "w-full mx-4 md:mx-auto text-center" ]
185 | content
186 | ]
187 |
--------------------------------------------------------------------------------
/src/elm/Routes/Home.elm:
--------------------------------------------------------------------------------
1 | module Routes.Home exposing
2 | ( Model
3 | , Msg
4 | , initModel
5 | , update
6 | , view
7 | )
8 |
9 | import Ant.Button as Btn
10 | import Ant.Form.View as FV
11 | import Ant.Layout as Layout
12 | import Ant.Typography as T
13 | import Html exposing (..)
14 | import Html.Attributes exposing (..)
15 | import Html.Events exposing (onClick)
16 | import Routes.Home.Forms as Forms exposing (..)
17 | import SharedState exposing (SharedState(..), SharedStateUpdate)
18 | import Utils
19 |
20 |
21 |
22 | -- MSG
23 |
24 |
25 | type Msg
26 | = LogInFormChanged LogInFormModel
27 | | SignUpFormChanged SignUpFormModel
28 | | ActiveFormToggled
29 | | LogIn String String
30 | | SignUp String String String String
31 | | FormSubmitted FormSubmittedResult
32 |
33 |
34 | type ActiveForm
35 | = LogInActive
36 | | SignUpActive
37 |
38 |
39 | type alias Model =
40 | { logInForm : LogInFormModel
41 | , signUpForm : SignUpFormModel
42 | , activeForm : ActiveForm
43 | }
44 |
45 |
46 | initModel : Model
47 | initModel =
48 | { logInForm = initialLogInFormModel
49 | , signUpForm = initialSignUpFormModel
50 | , activeForm = SignUpActive
51 | }
52 |
53 |
54 |
55 | -- UPDATE
56 |
57 |
58 | {-| Does no-ops for Cmd and ShareStateUpdate
59 | -}
60 | simpleUpdate : Model -> ( Model, Cmd Msg, SharedStateUpdate )
61 | simpleUpdate newModel =
62 | ( newModel, Cmd.none, SharedState.NoUpdate )
63 |
64 |
65 | update : SharedState -> Msg -> Model -> ( Model, Cmd Msg, SharedStateUpdate )
66 | update sharedState msg model =
67 | case msg of
68 | LogInFormChanged newFormState ->
69 | simpleUpdate { model | logInForm = newFormState }
70 |
71 | SignUpFormChanged newFormState ->
72 | simpleUpdate { model | signUpForm = newFormState }
73 |
74 | ActiveFormToggled ->
75 | let
76 | newActiveForm =
77 | case model.activeForm of
78 | LogInActive ->
79 | SignUpActive
80 |
81 | SignUpActive ->
82 | LogInActive
83 | in
84 | simpleUpdate { model | activeForm = newActiveForm }
85 |
86 | LogIn username password ->
87 | let
88 | api =
89 | Utils.getApi sharedState
90 |
91 | ( newFormModel, cmd ) =
92 | Forms.handleSubmitLogin
93 | api
94 | FormSubmitted
95 | model.logInForm
96 | { username = username
97 | , password = password
98 | }
99 | in
100 | ( { model | logInForm = newFormModel }
101 | , cmd
102 | , SharedState.NoUpdate
103 | )
104 |
105 | SignUp username email password passwordConfirm ->
106 | let
107 | api =
108 | Utils.getApi sharedState
109 |
110 | ( newFormModel, cmd ) =
111 | Forms.handleSubmitSignup
112 | api
113 | FormSubmitted
114 | model.signUpForm
115 | { username = username
116 | , password = password
117 | , email = email
118 | , passwordConfirm = passwordConfirm
119 | }
120 | in
121 | ( { model | signUpForm = newFormModel }
122 | , cmd
123 | , SharedState.NoUpdate
124 | )
125 |
126 | FormSubmitted submissionResult ->
127 | let
128 | ( cmd, sharedStateUpdate ) =
129 | Forms.handleFormSubmitted submissionResult sharedState
130 | in
131 | ( model, cmd, sharedStateUpdate )
132 |
133 |
134 |
135 | -- View
136 |
137 |
138 | type alias Title =
139 | String
140 |
141 |
142 | view : SharedState -> Model -> ( Title, Html Msg )
143 | view _ model =
144 | let
145 | logInForm =
146 | FV.toHtml
147 | { onChange = LogInFormChanged
148 | , action = "Log In"
149 | , loading = "loading ..."
150 | , validation = FV.ValidateOnSubmit
151 | }
152 | (Forms.logInForm LogIn)
153 | model.logInForm
154 |
155 | signUpForm =
156 | FV.toHtml
157 | { onChange = SignUpFormChanged
158 | , action = "Sign Up"
159 | , loading = "loading ..."
160 | , validation = FV.ValidateOnSubmit
161 | }
162 | (Forms.signUpForm SignUp)
163 | model.signUpForm
164 |
165 | activeForm =
166 | case model.activeForm of
167 | SignUpActive ->
168 | signUpForm
169 |
170 | LogInActive ->
171 | logInForm
172 |
173 | headerContent =
174 | div [] [ text "logo placeholder" ]
175 |
176 | toggleActiveFormButton =
177 | let
178 | buttonLabel =
179 | case model.activeForm of
180 | SignUpActive ->
181 | "or log in"
182 |
183 | LogInActive ->
184 | "or sign up"
185 | in
186 | Btn.button buttonLabel
187 | |> Btn.onClick ActiveFormToggled
188 | |> Btn.withType Btn.Link
189 | |> Btn.toHtml
190 |
191 | heading =
192 | let
193 | headingContent =
194 | case model.activeForm of
195 | SignUpActive ->
196 | "Sign Up To ParlezVous"
197 |
198 | LogInActive ->
199 | "Log In To ParlezVous"
200 | in
201 | T.title headingContent
202 | |> T.level T.H2
203 | |> T.toHtml
204 |
205 | mainContent =
206 | div []
207 | [ heading
208 | , activeForm
209 | , toggleActiveFormButton
210 | ]
211 |
212 | layoutContent =
213 | Layout.layout2
214 | (Layout.header headerContent)
215 | (Layout.content mainContent)
216 | in
217 | ( "Home", Layout.toHtml layoutContent )
218 |
--------------------------------------------------------------------------------
/src/elm/Routes/RegisterSite.elm:
--------------------------------------------------------------------------------
1 | module Routes.RegisterSite exposing
2 | ( Model
3 | , Msg
4 | , initModel
5 | , update
6 | , view
7 | )
8 |
9 | import Ant.Form as Form exposing (Form)
10 | import Ant.Form.View as FV
11 | import Api
12 | import Api.Deserialize as Input
13 | import Api.Output as Output
14 | import Browser.Navigation as Nav
15 | import Html exposing (..)
16 | import Html.Attributes exposing (class)
17 | import Http
18 | import SharedState exposing (PrivateState, SharedStateUpdate(..))
19 | import UI.Link exposing (externalLink)
20 | import UI.Nav as ResponsiveNav exposing (withVnav)
21 | import UI.Toast as Toast
22 | import Url exposing (Url)
23 |
24 |
25 | type alias FormValues =
26 | { domain : String
27 | }
28 |
29 |
30 | type alias Model =
31 | { registerSiteForm : FV.Model FormValues
32 | , toasts : Toast.ToastState
33 | , navbar : ResponsiveNav.NavState
34 | }
35 |
36 |
37 | type Msg
38 | = FormChanged (FV.Model FormValues)
39 | | RegisterSite Url
40 | | FormSubmitted (Result Http.Error Input.Site)
41 | | ToastMsg Toast.ToastMsg
42 | | ResponsiveNavMsg ResponsiveNav.Msg
43 |
44 |
45 | initModel : Model
46 | initModel =
47 | let
48 | formModel =
49 | FV.idle
50 | { domain = ""
51 | }
52 | in
53 | { registerSiteForm = formModel
54 | , toasts = Toast.init
55 | , navbar = ResponsiveNav.init
56 | }
57 |
58 |
59 | addToast : String -> ( Model, Cmd Msg ) -> ( Model, Cmd Msg )
60 | addToast =
61 | Toast.addToast ToastMsg
62 |
63 |
64 | form : Form FormValues Msg
65 | form =
66 | let
67 | domainInputField =
68 | Form.inputField
69 | { parser =
70 | \rawInput ->
71 | let
72 | withProtocol =
73 | "https://" ++ rawInput
74 | in
75 | case Url.fromString withProtocol of
76 | Nothing ->
77 | Err "Invalid URL"
78 |
79 | Just url ->
80 | -- Naive check to see if the URL is a FQDN
81 | if String.contains "." url.host then
82 | Ok url
83 |
84 | else
85 | Err "The Url must be a fully qualified domain name"
86 | , value = .domain
87 | , update = \value values -> { values | domain = value }
88 | , error = always Nothing
89 | , attributes =
90 | { label = "Domain Name"
91 | , placeholder = "example.com"
92 | }
93 | }
94 | in
95 | Form.succeed RegisterSite
96 | |> Form.append domainInputField
97 |
98 |
99 | update : PrivateState -> Msg -> Model -> ( Model, Cmd Msg, SharedStateUpdate )
100 | update state msg model =
101 | case msg of
102 | FormChanged newForm ->
103 | ( { model
104 | | registerSiteForm = newForm
105 | }
106 | , Cmd.none
107 | , NoUpdate
108 | )
109 |
110 | RegisterSite url ->
111 | let
112 | data =
113 | Output.RegisterSite url.host
114 |
115 | ( _, token ) =
116 | state.admin
117 |
118 | { registerSite } =
119 | Api.getApiClient state.api
120 | in
121 | ( model
122 | , registerSite token FormSubmitted data
123 | , NoUpdate
124 | )
125 |
126 | FormSubmitted result ->
127 | case result of
128 | Ok site ->
129 | ( model
130 | , UI.Link.toHref (UI.Link.Site site.id) |> Nav.pushUrl state.navKey
131 | , SharedState.InsertSite site
132 | )
133 |
134 | Err e ->
135 | let
136 | ( newModel, cmd ) =
137 | case e of
138 | Http.BadStatus statusCode ->
139 | if statusCode == 400 then
140 | ( model, Cmd.none )
141 | |> addToast "Make sure you enter a Fully Qualified Domain Name!"
142 |
143 | else if statusCode == 409 then
144 | ( model, Cmd.none )
145 | |> addToast "This Site is already registered!"
146 |
147 | else
148 | ( model, Cmd.none )
149 | |> addToast "Something went wrong"
150 |
151 | _ ->
152 | ( model, Cmd.none )
153 | |> addToast "Something went wrong"
154 | in
155 | ( newModel, cmd, NoUpdate )
156 |
157 | ToastMsg subMsg ->
158 | let
159 | ( m, cmd ) =
160 | model
161 | |> Toast.update ToastMsg subMsg
162 | in
163 | ( m
164 | , cmd
165 | , NoUpdate
166 | )
167 |
168 | ResponsiveNavMsg subMsg ->
169 | ResponsiveNav.update subMsg model
170 |
171 |
172 | type alias Title =
173 | String
174 |
175 |
176 | view : PrivateState -> Model -> ( Title, Html Msg )
177 | view state model =
178 | let
179 | viewWithNav =
180 | withVnav state model ResponsiveNavMsg
181 |
182 | domainInputForm =
183 | FV.toHtml
184 | { onChange = FormChanged
185 | , action = "Submit"
186 | , loading = "loading ..."
187 | , validation = FV.ValidateOnSubmit
188 | }
189 | form
190 | model.registerSiteForm
191 |
192 | content =
193 | div []
194 | [ h1 [] [ text "Register a domain" ]
195 | , text "Ensure that the domain you enter is a "
196 | , externalLink "https://en.wikipedia.org/wiki/Fully_qualified_domain_name" "fully-qualified domain name"
197 | , domainInputForm
198 | ]
199 |
200 | html =
201 | viewWithNav
202 | (div [ class "my-5 mx-8" ]
203 | [ content
204 | , Toast.view ToastMsg model.toasts
205 | ]
206 | )
207 | in
208 | ( "Register Site", html )
209 |
--------------------------------------------------------------------------------
/src/elm/Main.elm:
--------------------------------------------------------------------------------
1 | module Main exposing (main)
2 |
3 | import Ant.Css
4 | import Api exposing (Api)
5 | import Api.Deserialize as Input
6 | import Browser
7 | import Browser.Navigation as Nav
8 | import Html exposing (..)
9 | import Html.Attributes exposing (..)
10 | import Http
11 | import Router
12 | import SharedState exposing (SharedState(..))
13 | import Url
14 |
15 |
16 | type alias NotReadyData =
17 | { navKey : Nav.Key
18 | , api : Api
19 | }
20 |
21 |
22 | type alias AppData =
23 | { state : SharedState
24 | , router : Router.Model
25 | }
26 |
27 |
28 | type FailureCode
29 | = E_101
30 |
31 |
32 |
33 | -- | E_102
34 | -- | E_103
35 | -- | etc ...
36 |
37 |
38 | type alias FailureCodes =
39 | { flagApiIsInvalidUrl : FailureCode
40 | }
41 |
42 |
43 | failureCodes : FailureCodes
44 | failureCodes =
45 | { flagApiIsInvalidUrl = E_101
46 | }
47 |
48 |
49 | failureCodeToString : FailureCode -> String
50 | failureCodeToString _ =
51 | "e-101"
52 |
53 |
54 | type Model
55 | = Ready AppData
56 | -- represents a pending state for the application
57 | -- such as when we're checking with the server if a session token is valid
58 | | NotReady NotReadyData
59 | -- initialization failed
60 | | FailedInit FailureCode Nav.Key
61 |
62 |
63 | type Msg
64 | = UrlChanged Url.Url
65 | | LinkClicked Browser.UrlRequest
66 | | SessionVerified Input.SessionToken Url.Url Api (Result Http.Error Input.Admin)
67 | | RouterMsg Router.Msg
68 |
69 |
70 | main : Program Flags Model Msg
71 | main =
72 | Browser.application
73 | { init = init
74 | , update = update
75 | , view = view
76 | , subscriptions = \_ -> Sub.none
77 | , onUrlChange = UrlChanged
78 | , onUrlRequest = LinkClicked
79 | }
80 |
81 |
82 | type alias Flags =
83 | { token : Maybe Input.SessionToken
84 | , api : String
85 | }
86 |
87 |
88 |
89 | -- type alias Model = Int
90 |
91 |
92 | init : Flags -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
93 | init flags browserUrl key =
94 | let
95 | maybeApiUrl =
96 | Url.fromString flags.api
97 | in
98 | case ( maybeApiUrl, flags.token ) of
99 | -- The incoming Url from flags must be a valid URL, otherwise we can't make any API Requests
100 | ( Nothing, _ ) ->
101 | ( FailedInit failureCodes.flagApiIsInvalidUrl key, Cmd.none )
102 |
103 | ( Just apiUrl, Just token ) ->
104 | let
105 | api =
106 | Api.apiFactory apiUrl
107 |
108 | { getAdminSession } =
109 | Api.getApiClient api
110 | in
111 | ( NotReady <| NotReadyData key api
112 | , getAdminSession token <| SessionVerified token browserUrl api
113 | )
114 |
115 | ( Just apiUrl, Nothing ) ->
116 | let
117 | api =
118 | Api.apiFactory apiUrl
119 |
120 | sharedState =
121 | Public <| SharedState.init key api
122 |
123 | ( routerModel, routerCmd ) =
124 | Router.init browserUrl sharedState
125 |
126 | appData =
127 | { state = sharedState
128 | , router = routerModel
129 | }
130 | in
131 | ( Ready appData
132 | , Cmd.map RouterMsg routerCmd
133 | )
134 |
135 |
136 |
137 | -- UPDATE
138 |
139 |
140 | getNavKey : Model -> Nav.Key
141 | getNavKey model =
142 | case model of
143 | Ready { state } ->
144 | case state of
145 | Public { navKey } ->
146 | navKey
147 |
148 | Private { navKey } ->
149 | navKey
150 |
151 | NotReady { navKey } ->
152 | navKey
153 |
154 | FailedInit _ navKey ->
155 | navKey
156 |
157 |
158 | update : Msg -> Model -> ( Model, Cmd Msg )
159 | update msg model =
160 | case msg of
161 | RouterMsg routerMsg ->
162 | updateRouter routerMsg model
163 |
164 | UrlChanged url ->
165 | updateRouter (Router.UrlChange url) model
166 |
167 | LinkClicked urlRequest ->
168 | let
169 | _ =
170 | Debug.log "url request: " urlRequest
171 | in
172 | case urlRequest of
173 | Browser.Internal url ->
174 | ( model
175 | , Nav.pushUrl (getNavKey model) <| Url.toString url
176 | )
177 |
178 | -- leaving the app!
179 | Browser.External urlStr ->
180 | ( model
181 | , Nav.load urlStr
182 | )
183 |
184 | SessionVerified token browserUrl api result ->
185 | let
186 | publicState =
187 | SharedState.init key api
188 |
189 | sharedState =
190 | case result of
191 | Ok admin ->
192 | Private <|
193 | SharedState.toPrivate ( admin, token ) publicState
194 |
195 | _ ->
196 | Public publicState
197 |
198 | key =
199 | getNavKey model
200 |
201 | ( routerModel, routerCmd ) =
202 | Router.init browserUrl sharedState
203 | in
204 | ( Ready
205 | { state = sharedState
206 | , router = routerModel
207 | }
208 | , Cmd.map RouterMsg routerCmd
209 | )
210 |
211 |
212 | updateRouter : Router.Msg -> Model -> ( Model, Cmd Msg )
213 | updateRouter routerMsg model =
214 | case model of
215 | Ready appData ->
216 | let
217 | ( nextRouterModel, routerCmd, sharedStateUpdate ) =
218 | Router.update appData.state routerMsg appData.router
219 |
220 | nextSharedState =
221 | SharedState.update sharedStateUpdate appData.state
222 | in
223 | ( Ready
224 | { appData
225 | | state = nextSharedState
226 | , router = nextRouterModel
227 | }
228 | , Cmd.map RouterMsg routerCmd
229 | )
230 |
231 | _ ->
232 | ( model, Cmd.none )
233 |
234 |
235 |
236 | -- VIEW
237 |
238 |
239 | view : Model -> Browser.Document Msg
240 | view model =
241 | case model of
242 | Ready { state, router } ->
243 | let
244 | { title, body } =
245 | Router.view state router
246 | in
247 | { title = title
248 | , body = [ Ant.Css.defaultStyles, Html.map RouterMsg body ]
249 | }
250 |
251 | NotReady _ ->
252 | { title = "Loading ..."
253 | , body = [ div [] [ text "Loading ..." ] ]
254 | }
255 |
256 | FailedInit failureCode _ ->
257 | { title = "woops!"
258 | , body =
259 | [ div []
260 | [ text "Something went wrong :("
261 | , br [] []
262 | , text <| "Error code: " ++ failureCodeToString failureCode
263 | ]
264 | ]
265 | }
266 |
--------------------------------------------------------------------------------
/src/elm/Api.elm:
--------------------------------------------------------------------------------
1 | module Api exposing
2 | ( Api
3 | , ApiClient
4 | , apiFactory
5 | , getApiClient
6 | , makeCommonRequestUrl
7 | )
8 |
9 | import Api.Deserialize as Input
10 | import Api.Output as Output
11 | import Http
12 | import Json.Decode as D
13 | import Json.Encode as E
14 | import RemoteData
15 | import Url exposing (Url)
16 | import Url.Builder
17 |
18 |
19 | type Api
20 | = Api Url
21 |
22 |
23 | type alias ApiClient msg =
24 | { adminSignUp : AdminSignUp msg
25 | , adminLogIn : AdminLogIn msg
26 | , getAdminSession : GetAdminSession msg
27 | , getManySites : GetManySites msg
28 | , getSite : GetSite msg
29 | , registerSite : RegisterSite msg
30 | }
31 |
32 |
33 | type alias ToMsg a msg =
34 | Result Http.Error a -> msg
35 |
36 |
37 | type alias RequestTemplate =
38 | { method : String
39 | , headers : List Http.Header
40 | , tracker : Maybe String
41 | , timeout : Maybe Float
42 | }
43 |
44 |
45 | type Method
46 | = Get
47 | | Post
48 |
49 |
50 | apiFactory : Url -> Api
51 | apiFactory =
52 | Api
53 |
54 |
55 | getApiClient : Api -> ApiClient msg
56 | getApiClient api =
57 | { adminLogIn = adminSignin api
58 | , adminSignUp = adminSignup api
59 | , getAdminSession = getAdminSession api
60 | , getManySites = getSites api
61 | , getSite = getSingleSite api
62 | , registerSite = registerSite api
63 | }
64 |
65 |
66 | urlToString : Url -> String
67 | urlToString url =
68 | let
69 | raw =
70 | Url.toString url
71 | in
72 | if String.endsWith "/" raw then
73 | String.dropRight 1 raw
74 |
75 | else
76 | raw
77 |
78 |
79 | makeCommonRequestUrl : Api -> String -> String
80 | makeCommonRequestUrl (Api url) routePath =
81 | let
82 | commonPathRoot =
83 | "common"
84 |
85 | routePathList =
86 | String.split "/" routePath
87 |
88 | pathComponents =
89 | commonPathRoot :: routePathList
90 | in
91 | Url.Builder.crossOrigin
92 | (urlToString url)
93 | pathComponents
94 | []
95 |
96 |
97 | makeAdminRequestUrl : Api -> String -> String
98 | makeAdminRequestUrl (Api url) routePath =
99 | let
100 | adminPathRoot =
101 | "admins"
102 |
103 | routePathList =
104 | String.split "/" routePath
105 |
106 | pathComponents =
107 | adminPathRoot :: routePathList
108 | in
109 | Url.Builder.crossOrigin
110 | (urlToString url)
111 | pathComponents
112 | []
113 |
114 |
115 | secureRequestFactory : Method -> Input.SessionToken -> RequestTemplate
116 | secureRequestFactory method token =
117 | let
118 | rawMethod =
119 | case method of
120 | Get ->
121 | "GET"
122 |
123 | Post ->
124 | "POST"
125 | in
126 | { method = rawMethod
127 | , headers = [ Http.header "Authorization" token ]
128 | , tracker = Nothing
129 | , timeout = Nothing
130 | }
131 |
132 |
133 | securePost :
134 | String
135 | -> Input.SessionToken
136 | -> Http.Body
137 | -> Http.Expect msg
138 | -> Cmd msg
139 | securePost endpoint token body expect =
140 | let
141 | extraInfo =
142 | secureRequestFactory Post token
143 | in
144 | Http.request
145 | { method = extraInfo.method
146 | , url = endpoint
147 | , headers = extraInfo.headers
148 | , body = body
149 | , expect = expect
150 | , timeout = extraInfo.timeout
151 | , tracker = extraInfo.tracker
152 | }
153 |
154 |
155 | secureGet :
156 | String
157 | -> Input.SessionToken
158 | -> Http.Expect msg
159 | -> Cmd msg
160 | secureGet endpoint token expect =
161 | let
162 | extraInfo =
163 | secureRequestFactory Get token
164 | in
165 | Http.request
166 | { method = extraInfo.method
167 | , url = endpoint
168 | , headers = extraInfo.headers
169 | , body = Http.emptyBody
170 | , expect = expect
171 | , timeout = extraInfo.timeout
172 | , tracker = extraInfo.tracker
173 | }
174 |
175 |
176 |
177 | -- Api Requests
178 |
179 |
180 | type alias AdminSignUp msg =
181 | ToMsg Input.AdminWithToken msg -> Output.Signup -> Cmd msg
182 |
183 |
184 | adminSignup : Api -> AdminSignUp msg
185 | adminSignup api toMsg data =
186 | let
187 | signupJson =
188 | E.object
189 | [ ( "username", E.string data.username )
190 | , ( "email", E.string data.email )
191 | , ( "password", E.string data.password )
192 | , ( "passwordConfirm", E.string data.passwordConfirm )
193 | ]
194 |
195 | body =
196 | Http.jsonBody signupJson
197 |
198 | expect =
199 | Http.expectJson toMsg Input.adminAndTokenDecoder
200 | in
201 | Http.post
202 | { body = body
203 | , expect = expect
204 | , url = makeCommonRequestUrl api "signup"
205 | }
206 |
207 |
208 | type alias AdminLogIn msg =
209 | ToMsg Input.AdminWithToken msg -> Output.Signin -> Cmd msg
210 |
211 |
212 | adminSignin : Api -> AdminLogIn msg
213 | adminSignin api toMsg data =
214 | let
215 | signinJson =
216 | E.object
217 | [ ( "usernameOrEmail", E.string data.username )
218 | , ( "password", E.string data.password )
219 | ]
220 |
221 | body =
222 | Http.jsonBody signinJson
223 |
224 | expect =
225 | Http.expectJson toMsg Input.adminAndTokenDecoder
226 | in
227 | Http.post
228 | { body = body
229 | , expect = expect
230 | , url = makeCommonRequestUrl api "signin"
231 | }
232 |
233 |
234 |
235 | -- Private Routes
236 |
237 |
238 | type alias GetAdminSession msg =
239 | Input.SessionToken -> ToMsg Input.Admin msg -> Cmd msg
240 |
241 |
242 | getAdminSession : Api -> GetAdminSession msg
243 | getAdminSession api token toMsg =
244 | let
245 | expect =
246 | Http.expectJson toMsg (D.field "data" Input.adminDecoder)
247 | in
248 | secureGet
249 | (makeCommonRequestUrl api "profile")
250 | token
251 | expect
252 |
253 |
254 | type alias GetManySites msg =
255 | Input.SessionToken -> (RemoteData.WebData Input.Sites -> msg) -> Cmd msg
256 |
257 |
258 | getSites : Api -> GetManySites msg
259 | getSites api token toMsg =
260 | let
261 | sitesDecoder =
262 | D.field "data" (D.list Input.siteDecoder)
263 |
264 | expect =
265 | Http.expectJson (RemoteData.fromResult >> toMsg) sitesDecoder
266 | in
267 | secureGet
268 | (makeAdminRequestUrl api "sites")
269 | token
270 | expect
271 |
272 |
273 | type alias GetSite msg =
274 | Input.SessionToken -> String -> (RemoteData.WebData Input.Site -> msg) -> Cmd msg
275 |
276 |
277 | getSingleSite : Api -> GetSite msg
278 | getSingleSite api token siteId toMsg =
279 | let
280 | siteDecoder =
281 | D.field "data" Input.siteDecoder
282 |
283 | expect =
284 | Http.expectJson (RemoteData.fromResult >> toMsg) siteDecoder
285 | in
286 | secureGet
287 | (makeAdminRequestUrl api "sites/" ++ siteId)
288 | token
289 | expect
290 |
291 |
292 | type alias RegisterSite msg =
293 | Input.SessionToken -> ToMsg Input.Site msg -> Output.RegisterSite -> Cmd msg
294 |
295 |
296 | registerSite : Api -> RegisterSite msg
297 | registerSite api token toMsg data =
298 | let
299 | siteJson =
300 | E.object
301 | [ ( "hostname", E.string data.hostname )
302 | ]
303 |
304 | body =
305 | Http.jsonBody siteJson
306 |
307 | expect =
308 | Http.expectJson
309 | toMsg
310 | (D.field "data" Input.siteDecoder)
311 | in
312 | securePost
313 | (makeAdminRequestUrl api "sites/register")
314 | token
315 | body
316 | expect
317 |
--------------------------------------------------------------------------------
/src/elm/Routes/Home/Forms.elm:
--------------------------------------------------------------------------------
1 | module Routes.Home.Forms exposing
2 | ( FormSubmittedResult
3 | , LogInFormModel
4 | , LogInFormValues
5 | , SignUpFormModel
6 | , SignUpFormValues
7 | , handleFormSubmitted
8 | , handleSubmitLogin
9 | , handleSubmitSignup
10 | , initialLogInFormModel
11 | , initialSignUpFormModel
12 | , logInForm
13 | , signUpForm
14 | )
15 |
16 | import Ant.Form as Form exposing (Form)
17 | import Ant.Form.PasswordField exposing (PasswordFieldValue)
18 | import Ant.Form.View as FV
19 | import Api exposing (Api)
20 | import Api.Deserialize exposing (AdminWithToken)
21 | import Browser.Navigation as Nav
22 | import Email
23 | import Http
24 | import SharedState exposing (SharedState, SharedStateUpdate)
25 | import Utils
26 |
27 |
28 | type alias FormSubmittedResult =
29 | Result Http.Error AdminWithToken
30 |
31 |
32 | type alias SignUpFormModel =
33 | FV.Model SignUpFormValues
34 |
35 |
36 | type alias SignUpFormValues =
37 | { username : String
38 | , email : String
39 | , password : PasswordFieldValue
40 | , passwordConfirm : PasswordFieldValue
41 | }
42 |
43 |
44 | type alias LogInFormModel =
45 | FV.Model LogInFormValues
46 |
47 |
48 | type alias LogInFormValues =
49 | { username : String
50 | , password : PasswordFieldValue
51 | }
52 |
53 |
54 | {-| Initiates API request to server
55 | -}
56 | handleSubmitLogin :
57 | Api
58 | -> (FormSubmittedResult -> msg)
59 | -> LogInFormModel
60 | -> { username : String, password : String } --> 'a' ???
61 | -> ( LogInFormModel, Cmd msg )
62 | handleSubmitLogin api tagger formModel data =
63 | let
64 | { adminLogIn } =
65 | Api.getApiClient api
66 |
67 | cmd =
68 | adminLogIn tagger data
69 | in
70 | ( { formModel | state = FV.Loading }
71 | , cmd
72 | )
73 |
74 |
75 | handleSubmitSignup :
76 | Api
77 | -> (FormSubmittedResult -> msg)
78 | -> SignUpFormModel
79 | -> { email : String, username : String, password : String, passwordConfirm : String }
80 | -> ( SignUpFormModel, Cmd msg )
81 | handleSubmitSignup api tagger formModel data =
82 | let
83 | { adminSignUp } =
84 | Api.getApiClient api
85 |
86 | cmd =
87 | adminSignUp tagger data
88 | in
89 | ( { formModel | state = FV.Loading }
90 | , cmd
91 | )
92 |
93 |
94 | {-| Handles server response
95 | -}
96 | handleFormSubmitted : FormSubmittedResult -> SharedState -> ( Cmd msg, SharedStateUpdate )
97 | handleFormSubmitted result state =
98 | case result of
99 | Ok adminWithToken ->
100 | let
101 | navKey =
102 | Utils.getNavKey state
103 |
104 | commands =
105 | Cmd.batch
106 | [ Utils.setToken <| Tuple.second adminWithToken
107 | , Nav.pushUrl navKey "/dash"
108 | ]
109 | in
110 | ( commands
111 | , SharedState.SetAdmin adminWithToken
112 | )
113 |
114 | Err e ->
115 | let
116 | _ =
117 | Debug.log "[handleFormSubmitted] Error - " e
118 | in
119 | ( Cmd.none
120 | , SharedState.NoUpdate
121 | )
122 |
123 |
124 |
125 | {-
126 | updateFormState : Model -> FV.State -> Model
127 | updateFormState ({ loginForm } as model) newState =
128 | { model
129 | | loginForm = { loginForm | state = newState }
130 | }
131 | -}
132 |
133 |
134 | initialSignUpFormModel : SignUpFormModel
135 | initialSignUpFormModel =
136 | let
137 | initialPasswordFieldValue =
138 | { value = ""
139 | , textVisible = False
140 | }
141 | in
142 | FV.idle
143 | { username = ""
144 | , email = ""
145 | , password = initialPasswordFieldValue
146 | , passwordConfirm = initialPasswordFieldValue
147 | }
148 |
149 |
150 | signUpForm :
151 | (String -> String -> String -> String -> msg)
152 | -> Form SignUpFormValues msg
153 | signUpForm tagger =
154 | let
155 | usernameField =
156 | Form.inputField
157 | { parser = Ok
158 | , value = .username
159 | , update = \username values -> { values | username = username }
160 | , error = always Nothing
161 | , attributes =
162 | { label = "Username"
163 | , placeholder = ""
164 | }
165 | }
166 |
167 | emailField =
168 | Form.inputField
169 | { parser =
170 | \rawEmail ->
171 | if Email.isValid rawEmail then
172 | Ok rawEmail
173 |
174 | else
175 | Err "Invalid email"
176 | , value = .email
177 | , update = \email values -> { values | email = email }
178 | , error = always Nothing
179 | , attributes =
180 | { label = "Email"
181 | , placeholder = ""
182 | }
183 | }
184 |
185 | passwordField =
186 | Form.passwordField
187 | { parser = \{ value } -> Ok value
188 | , value = .password
189 | , update = \pswrd values -> { values | password = pswrd }
190 | , error = always Nothing
191 | , attributes =
192 | { label = "Password"
193 | , placeholder = ""
194 | }
195 | }
196 |
197 | passwordConfirmField =
198 | Form.meta
199 | (\values ->
200 | Form.passwordField
201 | { parser =
202 | \{ value } ->
203 | if value == values.password.value then
204 | Ok value
205 |
206 | else
207 | Err "The passwords do not match"
208 | , value = .passwordConfirm
209 | , update = \pswrd vals -> { vals | passwordConfirm = pswrd }
210 | , error = always Nothing
211 | , attributes =
212 | { label = "Confirm Password"
213 | , placeholder = ""
214 | }
215 | }
216 | )
217 | in
218 | Form.succeed tagger
219 | |> Form.append usernameField
220 | |> Form.append emailField
221 | |> Form.append passwordField
222 | |> Form.append passwordConfirmField
223 |
224 |
225 | initialLogInFormModel : LogInFormModel
226 | initialLogInFormModel =
227 | FV.idle
228 | { username = ""
229 | , password = { value = "", textVisible = False }
230 | }
231 |
232 |
233 | logInForm : (String -> String -> msg) -> Form LogInFormValues msg
234 | logInForm tagger =
235 | let
236 | usernameField =
237 | Form.inputField
238 | { parser = Ok
239 | , value = .username
240 | , update = \username values -> { values | username = username }
241 | , error = always Nothing
242 | , attributes =
243 | { label = "Username"
244 | , placeholder = ""
245 | }
246 | }
247 |
248 | passwordField =
249 | Form.passwordField
250 | { parser = \{ value } -> Ok value
251 | , value = .password
252 | , update = \pswrd values -> { values | password = pswrd }
253 | , error = always Nothing
254 | , attributes =
255 | { label = "Password"
256 | , placeholder = ""
257 | }
258 | }
259 | in
260 | Form.succeed tagger
261 | |> Form.append usernameField
262 | |> Form.append passwordField
263 |
--------------------------------------------------------------------------------
/src/elm/Router.elm:
--------------------------------------------------------------------------------
1 | module Router exposing
2 | ( Model
3 | , Msg(..)
4 | , init
5 | , update
6 | , view
7 | )
8 |
9 | import Api
10 | import Api.Deserialize as Input
11 | import Browser.Navigation as Nav
12 | import Html as Html exposing (..)
13 | import Html.Attributes exposing (class)
14 | import RemoteData exposing (WebData)
15 | import Routes.Dash as Dash
16 | import Routes.Home as Home
17 | import Routes.RegisterSite as RegisterSite
18 | import Routes.Site as Site
19 | import SharedState exposing (PrivateState, SharedState(..), SharedStateUpdate)
20 | import UI.Toast as Toast
21 | import Url exposing (Url)
22 | import Url.Parser as Parser exposing ((>), Parser, oneOf, string)
23 |
24 |
25 | type Route
26 | = Home Home.Model
27 | | Dash Dash.Model
28 | | Site Site.Model
29 | | RegisterSite RegisterSite.Model
30 | | NotFound
31 |
32 |
33 | type alias Model =
34 | { activeRoute : Route
35 | , toasts : Toast.ToastState
36 | }
37 |
38 |
39 | type Msg
40 | = UrlChange Url
41 | | ToastMsg Toast.ToastMsg
42 | | SitesResponse (WebData Input.Sites)
43 | | HomeMsg Home.Msg
44 | | DashMsg Dash.Msg
45 | | SiteMsg Site.Msg
46 | | RegisterSiteMsg RegisterSite.Msg
47 |
48 |
49 | parser : Parser (Route -> a) a
50 | parser =
51 | oneOf
52 | [ Parser.map (Home Home.initModel) Parser.top
53 | , Parser.map (Dash Dash.initModel) (Parser.s "dash")
54 | , Parser.map (Site << Site.initModel) (Parser.s "sites" > string)
55 |
56 | --, Parser.map (Login Login.initModel) (Parser.s "login")
57 | --, Parser.map (Signup Signup.initModel) (Parser.s "signup")
58 | , Parser.map (RegisterSite RegisterSite.initModel) (Parser.s "register-site")
59 | ]
60 |
61 |
62 | fromUrl : Url -> Route
63 | fromUrl =
64 | Maybe.withDefault NotFound << Parser.parse parser
65 |
66 |
67 | init : Url -> SharedState -> ( Model, Cmd Msg )
68 | init url sharedState =
69 | let
70 | route =
71 | fromUrl url
72 | in
73 | ( { activeRoute = route, toasts = Toast.init }
74 | , transitionTrigger route sharedState
75 | )
76 |
77 |
78 | {-| trigger commands on page transitions
79 | and initializations
80 | -}
81 | transitionTrigger : Route -> SharedState -> Cmd Msg
82 | transitionTrigger route state =
83 | case ( route, state ) of
84 | -- redirect guests on private routes
85 | ( Dash _, Public { navKey } ) ->
86 | Nav.pushUrl navKey "/"
87 |
88 | ( Site _, Public { navKey } ) ->
89 | Nav.pushUrl navKey "/"
90 |
91 | ( RegisterSite _, Public { navKey } ) ->
92 | Nav.pushUrl navKey "/"
93 |
94 | -- redirect authed users away from public routes
95 | ( Home _, Private { navKey } ) ->
96 | Nav.pushUrl navKey "/dash"
97 |
98 | ( _, Private { admin, api, sites } ) ->
99 | let
100 | ( _, token ) =
101 | admin
102 |
103 | { getManySites } =
104 | Api.getApiClient api
105 | in
106 | case sites of
107 | RemoteData.NotAsked ->
108 | getManySites token SitesResponse
109 |
110 | _ ->
111 | Cmd.none
112 |
113 | _ ->
114 | Cmd.none
115 |
116 |
117 | update : SharedState -> Msg -> Model -> ( Model, Cmd Msg, SharedStateUpdate )
118 | update state msg model =
119 | case ( msg, model.activeRoute ) of
120 | ( UrlChange url, _ ) ->
121 | let
122 | newRoute =
123 | fromUrl url
124 |
125 | transitionTriggerMsg =
126 | transitionTrigger newRoute state
127 | in
128 | ( { model | activeRoute = newRoute }
129 | , transitionTriggerMsg
130 | , SharedState.NoUpdate
131 | )
132 |
133 | ( SitesResponse response, _ ) ->
134 | case response of
135 | RemoteData.Success sites ->
136 | ( model
137 | , Cmd.none
138 | , SharedState.UpdateSites <| SharedState.toDict sites
139 | )
140 |
141 | RemoteData.Failure _ ->
142 | let
143 | ( newModel, cmd ) =
144 | ( model, Cmd.none )
145 | |> Toast.addToast ToastMsg "Something went wrong"
146 | in
147 | ( newModel, cmd, SharedState.NoUpdate )
148 |
149 | _ ->
150 | ( model, Cmd.none, SharedState.NoUpdate )
151 |
152 | ( HomeMsg homeMsg, Home homeModel ) ->
153 | let
154 | ( newHomeModel, homeCmd, sharedStateUpdate ) =
155 | Home.update state homeMsg homeModel
156 | in
157 | ( { model | activeRoute = Home newHomeModel }
158 | , Cmd.map HomeMsg homeCmd
159 | , sharedStateUpdate
160 | )
161 |
162 | ( DashMsg dashMsg, Dash dashModel ) ->
163 | case state of
164 | Private privateState ->
165 | let
166 | ( newDashModel, dashCmd, sharedStateUpdate ) =
167 | Dash.update privateState dashMsg dashModel
168 | in
169 | ( { model | activeRoute = Dash newDashModel }
170 | , Cmd.map DashMsg dashCmd
171 | , sharedStateUpdate
172 | )
173 |
174 | Public _ ->
175 | ( { model | activeRoute = Dash dashModel }
176 | , Cmd.none
177 | , SharedState.NoUpdate
178 | )
179 |
180 | ( SiteMsg siteMsg, Site siteModel ) ->
181 | case state of
182 | Private privateState ->
183 | let
184 | ( newSiteModel, siteCmd, sharedStateUpdate ) =
185 | Site.update privateState siteMsg siteModel
186 | in
187 | ( { model | activeRoute = Site newSiteModel }
188 | , Cmd.map SiteMsg siteCmd
189 | , sharedStateUpdate
190 | )
191 |
192 | Public _ ->
193 | ( model
194 | , Cmd.none
195 | , SharedState.NoUpdate
196 | )
197 |
198 | ( RegisterSiteMsg registerSiteMsg, RegisterSite registersiteModel ) ->
199 | case state of
200 | Private privateState ->
201 | let
202 | ( newRegisterSiteModel, registerSiteCmd, sharedStateUpdate ) =
203 | RegisterSite.update privateState registerSiteMsg registersiteModel
204 | in
205 | ( { model | activeRoute = RegisterSite newRegisterSiteModel }
206 | , Cmd.map RegisterSiteMsg registerSiteCmd
207 | , sharedStateUpdate
208 | )
209 |
210 | Public _ ->
211 | ( model
212 | , Cmd.none
213 | , SharedState.NoUpdate
214 | )
215 |
216 | -- Placeholder for now
217 | _ ->
218 | ( model, Cmd.none, SharedState.NoUpdate )
219 |
220 |
221 |
222 | {--Generalize the following
223 | Site.view privateState siteModel
224 | |> Tuple.mapSecond (Html.map <| SiteMsg siteModel)
225 | |> Tuple.mapSecond (Html.map toMsg)
226 | --}
227 |
228 |
229 | type alias Title =
230 | String
231 |
232 |
233 | type alias AppView =
234 | { title : Title
235 | , body : Html Msg
236 | }
237 |
238 |
239 | viewPrivatePage :
240 | SharedState
241 | -> (PrivateState -> m -> ( Title, Html msg ))
242 | -> m
243 | -> (msg -> Msg)
244 | -> ( Title, Html Msg )
245 | viewPrivatePage sharedState routeView model tagger =
246 | let
247 | redirectPage =
248 | ( "Redirecting ..."
249 | , div [] [ text "Redirecting ..." ]
250 | )
251 | in
252 | case sharedState of
253 | Public _ ->
254 | redirectPage
255 |
256 | Private privateState ->
257 | routeView privateState model
258 | |> Tuple.mapSecond (Html.map tagger)
259 |
260 |
261 | view : SharedState -> Model -> AppView
262 | view sharedState model =
263 | let
264 | toastView =
265 | Toast.view ToastMsg model.toasts
266 |
267 | viewPrivateRoute =
268 | viewPrivatePage sharedState
269 |
270 | ( title, html ) =
271 | case model.activeRoute of
272 | Home homeModel ->
273 | Home.view sharedState homeModel
274 | |> Tuple.mapSecond (Html.map HomeMsg)
275 |
276 | Dash dashModel ->
277 | viewPrivateRoute
278 | Dash.view
279 | dashModel
280 | DashMsg
281 |
282 | Site siteModel ->
283 | viewPrivateRoute
284 | Site.view
285 | siteModel
286 | SiteMsg
287 |
288 | RegisterSite registerSiteModel ->
289 | viewPrivateRoute
290 | RegisterSite.view
291 | registerSiteModel
292 | RegisterSiteMsg
293 |
294 | NotFound ->
295 | ( "Woops!", div [] [ text "404 Not Found" ] )
296 | in
297 | { title = title ++ " | Parlez-Vous "
298 | , body = div [ class "bg-gray-100" ] [ html, toastView ]
299 | }
300 |
--------------------------------------------------------------------------------