From 56ee2cf145f136f0b7ed8b4410bae5170c692d11 Mon Sep 17 00:00:00 2001 From: Dillon Kearns Date: Mon, 23 May 2022 19:45:52 -0700 Subject: [PATCH] Add beginning of todo example. --- examples/trails/app/Route/Login.elm | 67 ++-- examples/trails/app/Route/Todos.elm | 392 +++++++++++++++++++ examples/trails/style.css | 580 ++++++++++++++++++++++++---- 3 files changed, 935 insertions(+), 104 deletions(-) create mode 100644 examples/trails/app/Route/Todos.elm diff --git a/examples/trails/app/Route/Login.elm b/examples/trails/app/Route/Login.elm index 5727ccaa..4e44763c 100644 --- a/examples/trails/app/Route/Login.elm +++ b/examples/trails/app/Route/Login.elm @@ -37,7 +37,20 @@ route = RouteBuilder.serverRender { head = head , data = data - , action = \_ -> Request.skip "No action." + , action = + \_ -> + MySession.withSession + (Request.expectFormPost (\{ field } -> field "name")) + (\name session -> + ( session + |> Result.withDefault Nothing + |> Maybe.withDefault Session.empty + |> Session.insert "userId" name + |> Session.withFlash "message" ("Welcome " ++ name ++ "!") + , Route.redirectTo Route.Todos + ) + |> DataSource.succeed + ) } |> RouteBuilder.buildNoState { view = view } @@ -50,40 +63,26 @@ type alias Request = data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage)) data routeParams = - Request.oneOf - [ MySession.withSession - (Request.expectFormPost (\{ field } -> field "name")) - (\name session -> - ( session - |> Result.withDefault Nothing - |> Maybe.withDefault Session.empty - |> Session.insert "name" name - |> Session.withFlash "message" ("Welcome " ++ name ++ "!") - , Route.redirectTo Route.Index - ) - |> DataSource.succeed - ) - , MySession.withSession - (Request.succeed ()) - (\() session -> - case session of - Ok (Just okSession) -> - ( okSession - , okSession - |> Session.get "name" - |> Data - |> Server.Response.render - ) - |> DataSource.succeed + MySession.withSession + (Request.succeed ()) + (\() session -> + case session of + Ok (Just okSession) -> + ( okSession + , okSession + |> Session.get "name" + |> Data + |> Server.Response.render + ) + |> DataSource.succeed - _ -> - ( Session.empty - , { username = Nothing } - |> Server.Response.render - ) - |> DataSource.succeed - ) - ] + _ -> + ( Session.empty + , { username = Nothing } + |> Server.Response.render + ) + |> DataSource.succeed + ) head : diff --git a/examples/trails/app/Route/Todos.elm b/examples/trails/app/Route/Todos.elm new file mode 100644 index 00000000..48cd5bb5 --- /dev/null +++ b/examples/trails/app/Route/Todos.elm @@ -0,0 +1,392 @@ +module Route.Todos exposing (ActionData, Data, Model, Msg, route) + +import Api.InputObject +import Api.Mutation +import Api.Object exposing (Todos) +import Api.Object.Todos +import Api.Query +import DataSource exposing (DataSource) +import Effect exposing (Effect) +import ErrorPage exposing (ErrorPage) +import Graphql.Operation exposing (RootQuery) +import Graphql.OptionalArgument exposing (OptionalArgument(..)) +import Graphql.SelectionSet as SelectionSet exposing (SelectionSet) +import Head +import Head.Seo as Seo +import Html exposing (Html) +import Html.Attributes as Attr +import Html.Events +import MySession +import Pages.Msg +import Pages.PageUrl exposing (PageUrl) +import Pages.Url +import Path exposing (Path) +import Request.Hasura +import Route +import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload) +import Server.Request as Request +import Server.Response as Response exposing (Response) +import Server.Session as Session +import Shared +import Time +import View exposing (View) + + +type alias Model = + {} + + +type Msg + = NoOp + + +type alias RouteParams = + {} + + +route : StatefulRoute RouteParams Data ActionData Model Msg +route = + RouteBuilder.serverRender + { head = head + , data = data + , action = action + } + |> RouteBuilder.buildWithLocalState + { view = view + , update = update + , subscriptions = subscriptions + , init = init + } + + +init : + Maybe PageUrl + -> Shared.Model + -> StaticPayload Data ActionData RouteParams + -> ( Model, Effect Msg ) +init maybePageUrl sharedModel static = + ( {}, Effect.none ) + + +update : + PageUrl + -> Shared.Model + -> StaticPayload Data ActionData RouteParams + -> Msg + -> Model + -> ( Model, Effect Msg ) +update pageUrl sharedModel static msg model = + case msg of + NoOp -> + ( model, Effect.none ) + + +subscriptions : Maybe PageUrl -> RouteParams -> Path -> Shared.Model -> Model -> Sub Msg +subscriptions maybePageUrl routeParams path sharedModel model = + Sub.none + + +type alias Data = + { todos : List Todo + } + + +type alias ActionData = + {} + + +todosByUserId : Int -> SelectionSet (List Todo) RootQuery +todosByUserId userId = + Api.Query.todos + (\optionals -> + { optionals + | where_ = + Present + (Api.InputObject.buildTodos_bool_exp + (\whereOptionals -> + { whereOptionals + | user_id = + Api.InputObject.buildInt_comparison_exp + (\intOptionals -> + { intOptionals | eq_ = Present <| userId } + ) + |> Present + } + ) + ) + } + ) + (SelectionSet.map3 Todo + Api.Object.Todos.title + Api.Object.Todos.is_completed + Api.Object.Todos.id + ) + + +data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage)) +data routeParams = + Request.requestTime + |> MySession.expectSessionOrRedirect + (\requestTime session -> + let + maybeUserId : Maybe Int + maybeUserId = + session + |> Session.get "userId" + |> Maybe.andThen String.toInt + in + case maybeUserId of + Just userId -> + todosByUserId userId + |> Request.Hasura.dataSource (requestTime |> Time.posixToMillis |> String.fromInt) + |> DataSource.map + (\todos -> + ( session + , Response.render { todos = todos } + ) + ) + + Nothing -> + ( session, Route.redirectTo Route.Login ) + |> DataSource.succeed + ) + + +action : RouteParams -> Request.Parser (DataSource (Response ActionData ErrorPage)) +action routeParams = + let + userId = + 1 + in + Request.expectFormPost + (\{ field } -> + Request.oneOf + [ field "newTodo" + |> Request.map + (\title -> + createTodo userId title + |> Request.Hasura.mutationDataSource "" + |> DataSource.map + (\_ -> Response.render {}) + ) + , field "deleteId" + |> Request.map + (\deleteId -> + -- TODO use RBAC here in Hasura? + deleteTodo userId (deleteId |> String.toInt |> Maybe.withDefault 0) + |> Request.Hasura.mutationDataSource "" + |> DataSource.map + (\_ -> Response.render {}) + ) + ] + ) + + +createTodo : Int -> String -> SelectionSet (Maybe ()) Graphql.Operation.RootMutation +createTodo userId title = + Api.Mutation.insert_todos_one identity + { object = + Api.InputObject.buildTodos_insert_input + (\optionals -> + { optionals + | title = Present title + , user_id = Present userId + } + ) + } + SelectionSet.empty + + +deleteTodo : Int -> Int -> SelectionSet (Maybe ()) Graphql.Operation.RootMutation +deleteTodo userId todoId = + Api.Mutation.delete_todos_by_pk { id = todoId } + SelectionSet.empty + + +head : + StaticPayload Data ActionData RouteParams + -> List Head.Tag +head static = + Seo.summary + { canonicalUrlOverride = Nothing + , siteName = "elm-pages" + , image = + { url = Pages.Url.external "TODO" + , alt = "elm-pages logo" + , dimensions = Nothing + , mimeType = Nothing + } + , description = "TODO" + , locale = Nothing + , title = "Todo List" + } + |> Seo.website + + +view : + Maybe PageUrl + -> Shared.Model + -> Model + -> StaticPayload Data ActionData RouteParams + -> View (Pages.Msg.Msg Msg) +view maybeUrl sharedModel model static = + { title = "Todo List" + , body = + [ Html.div + [ Attr.class "todomvc-wrapper" + ] + [ Html.section + [ Attr.class "todoapp" + ] + [ Html.header + [ Attr.class "header" + ] + [ Html.h1 [] + [ Html.text "todos" ] + , Html.form + [ Attr.method "POST" + , Pages.Msg.fetcherOnSubmit + ] + [ Html.input + [ Attr.class "new-todo" + , Attr.placeholder "What needs to be done?" + , Attr.autofocus True + , Attr.name "newTodo" + ] + [] + , Html.button [] [ Html.text "Create" ] + ] + ] + , Html.section + [ Attr.class "main" + , Attr.style "visibility" "visible" + ] + [ Html.input + [ Attr.class "toggle-all" + , Attr.id "toggle-all" + , Attr.type_ "checkbox" + , Attr.name "toggle" + ] + [] + , Html.label + [ Attr.for "toggle-all" + ] + [ Html.text "Mark all as complete" ] + , Html.ul + [ Attr.class "todo-list" + ] + (static.data.todos + |> List.map todoItemView + ) + ] + , Html.footer + [ Attr.class "footer" + ] + [ Html.span + [ Attr.class "todo-count" + ] + [ Html.strong [] + [ Html.text "3" ] + , Html.text " items left" + ] + , Html.ul + [ Attr.class "filters" + ] + [ Html.li [] + [ Html.a + [ Attr.class "selected" + , Attr.href "#/" + ] + [ Html.text "All" ] + ] + , Html.li [] + [ Html.a + [ Attr.class "" + , Attr.href "#/active" + ] + [ Html.text "Active" ] + ] + , Html.li [] + [ Html.a + [ Attr.class "" + , Attr.href "#/completed" + ] + [ Html.text "Completed" ] + ] + ] + , Html.button + [ Attr.class "clear-completed" + , Attr.hidden True + ] + [ Html.text "Clear completed (0)" ] + ] + ] + , Html.footer + [ Attr.class "info" + ] + [ Html.p [] + [ Html.text "Double-click to edit a todo" ] + , Html.p [] + [ Html.text "Written by " + , Html.a + [ Attr.href "https://github.com/dillonkearns" + ] + [ Html.text "Dillon Kearns" ] + ] + , Html.p [] + [ Html.text "Part of " + , Html.a + [ Attr.href "http://todomvc.com" + ] + [ Html.text "TodoMVC" ] + ] + ] + ] + ] + } + + +type alias Todo = + { title : String + , complete : Bool + , id : Int + } + + +todoItemView : Todo -> Html (Pages.Msg.Msg Msg) +todoItemView todo = + Html.li [] + [ Html.div + [ Attr.class "view" + , Pages.Msg.fetcherOnSubmit + ] + [ Html.form + [ Attr.method "POST" + ] + [ Html.input + [ Attr.class "toggle" + , Attr.type_ "checkbox" + , Attr.checked todo.complete + + --, Html.Events.onCheck (\_ -> Pages.Msg.Submit ) + ] + [] + , Html.label [] + [ Html.text todo.title ] + ] + , Html.form [ Attr.method "POST", Pages.Msg.fetcherOnSubmit ] + [ Html.button + [ Attr.class "destroy" + ] + [] + , Html.input [ Attr.type_ "hidden", Attr.name "deleteId", Attr.value (String.fromInt todo.id) ] [] + ] + ] + , Html.input + [ Attr.class "edit" + , Attr.name "title" + + --, Attr.id "todo-0" + ] + [] + ] diff --git a/examples/trails/style.css b/examples/trails/style.css index f57e5b77..b1b2b451 100644 --- a/examples/trails/style.css +++ b/examples/trails/style.css @@ -1,80 +1,520 @@ -@import url("https://rsms.me/inter/inter.css"); -@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap"); +hr { + margin: 20px 0; + border: 0; + border-top: 1px dashed #c5c5c5; + border-bottom: 1px dashed #f7f7f7; +} + +.learn a { + font-weight: normal; + text-decoration: none; + color: #b83f45; +} + +.learn a:hover { + text-decoration: underline; + color: #787e7e; +} + +.learn h3, +.learn h4, +.learn h5 { + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + color: #000; +} + +.learn h3 { + font-size: 24px; +} + +.learn h4 { + font-size: 18px; +} + +.learn h5 { + margin-bottom: 0; + font-size: 14px; +} + +.learn ul { + padding: 0; + margin: 0 0 30px 25px; +} + +.learn li { + line-height: 20px; +} + +.learn p { + font-size: 15px; + font-weight: 300; + line-height: 1.3; + margin-top: 0; + margin-bottom: 0; +} + +#issue-count { + display: none; +} + +.quote { + border: none; + margin: 20px 0 60px 0; +} + +.quote p { + font-style: italic; +} + +.quote p:before { + content: '“'; + font-size: 50px; + opacity: .15; + position: absolute; + top: -20px; + left: 3px; +} + +.quote p:after { + content: '”'; + font-size: 50px; + opacity: .15; + position: absolute; + bottom: -42px; + right: 3px; +} + +.quote footer { + position: absolute; + bottom: -40px; + right: 0; +} + +.quote footer img { + border-radius: 3px; +} + +.quote footer a { + margin-left: 5px; + vertical-align: middle; +} + +.speech-bubble { + position: relative; + padding: 10px; + background: rgba(0, 0, 0, .04); + border-radius: 5px; +} + +.speech-bubble:after { + content: ''; + position: absolute; + top: 100%; + right: 30px; + border: 13px solid transparent; + border-top-color: rgba(0, 0, 0, .04); +} + +.learn-bar > .learn { + position: absolute; + width: 272px; + top: 8px; + left: -300px; + padding: 10px; + border-radius: 5px; + background-color: rgba(255, 255, 255, .6); + transition-property: left; + transition-duration: 500ms; +} + +@media (min-width: 899px) { + .learn-bar { + width: auto; + padding-left: 300px; + } + + .learn-bar > .learn { + left: 8px; + } +} +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} body { - font-family: "Inter var" !important; + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; + width: auto; + + +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + text-align: center; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; +} + +.toggle-all + label { + width: 60px; + height: 34px; + font-size: 0; + position: absolute; + top: -52px; + left: -13px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all + label:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked + label:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; } /* - -input:invalid { - border: 2px dashed red; -} - -input:valid { - border: 2px solid black; -} - + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox */ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } - -input[type=password], input[type=text], input[type=date], input[type=email] { - border-radius: 10px !important; - border-color: #ccc !important; + .todo-list li .toggle { + height: 40px; + } } -input[type=checkbox] { - border-radius: 4px !important; - border-color: #ccc !important; -} +@media (max-width: 430px) { + .footer { + height: 50px; + } -main.color-app { - align-items: center; - display: flex; - justify-content: center; - min-height: 100vh; - box-sizing: border-box; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, - Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; - font-size: 18px; - margin: 0; - padding: 0; -} - -main.color-app { - background: rgba(0, 0, 0, 0.15); - border-radius: 0.8em; - padding: 0.8em; -} - -main.color-app .content { - background: rgba(255, 255, 255, 0.9); - border-radius: 0.5em; - max-width: 90vw; - padding: 2rem 3rem; - width: 475px; - color: var(--selected-color); -} - -main.color-app .content h1 { - margin: 0; -} - -main.color-app .content ul { - list-style: none; - padding: 0; -} - -main.color-app .content li { - margin-left: -1rem; - margin-right: -1rem; - padding: 1rem; -} - -main.color-app .content li:nth-child(odd) { - background: rgb(100 10 80 / 0.1); -} - -.timestamp { - font-size: 0.75rem; -} + .filters { + bottom: 10px; + } +} \ No newline at end of file