diff --git a/client/src/elm/Cards/Card.elm b/client/src/elm/Cards/Card.elm index 8320de5..91a9b0b 100644 --- a/client/src/elm/Cards/Card.elm +++ b/client/src/elm/Cards/Card.elm @@ -12,7 +12,7 @@ import Html.Attributes as HtmlA type Mutability value msg = Immutable - | Mutable (value -> msg) + | Mutable (value -> msg) (List (Html.Attribute msg)) type Side @@ -32,7 +32,7 @@ view type_ mutability source content meta visibleSide = Immutable -> "immutable" - Mutable _ -> + Mutable _ _ -> "mutable" typeClass = diff --git a/client/src/elm/Cards/Response.elm b/client/src/elm/Cards/Response.elm index b928f58..485888c 100644 --- a/client/src/elm/Cards/Response.elm +++ b/client/src/elm/Cards/Response.elm @@ -39,12 +39,13 @@ view mutability side source (Response text) = Card.Immutable -> [ Html.text text ] - Card.Mutable update -> + Card.Mutable update attrs -> [ Html.textarea - [ HtmlA.value text - , HtmlE.onInput (Response >> update) - , HtmlA.placeholder "type a response here" - ] + ([ HtmlA.value text + , HtmlE.onInput (fromString >> update) + ] + ++ attrs + ) [] ] in @@ -57,8 +58,8 @@ toString (Response text) = fromString : String -> Response -fromString text = - Response text +fromString = + String.replace "\n" "" >> Response encode : Response -> Json.Value diff --git a/client/src/elm/ManyDecks.elm b/client/src/elm/ManyDecks.elm index f8293f1..cb48c08 100644 --- a/client/src/elm/ManyDecks.elm +++ b/client/src/elm/ManyDecks.elm @@ -85,11 +85,19 @@ subscriptions model = Err error -> error |> Error.BadResponse |> Error.Application |> SetError + + edit = + case model.route of + Decks (DecksRoute.Edit code) -> + model.edit |> Maybe.map (Edit.subscriptions code) |> Maybe.withDefault Sub.none + + _ -> + Sub.none in Sub.batch [ Ports.googleAuthResult googleAuthResultToMessage , Ports.json5Decoded json5DecodedToMessage - , model.edit |> Maybe.map Edit.subscriptions |> Maybe.withDefault Sub.none + , edit ] diff --git a/client/src/elm/ManyDecks/Pages/Decks.elm b/client/src/elm/ManyDecks/Pages/Decks.elm index 4657ce4..5b3211f 100644 --- a/client/src/elm/ManyDecks/Pages/Decks.elm +++ b/client/src/elm/ManyDecks/Pages/Decks.elm @@ -91,7 +91,7 @@ update msg model = Just auth -> let updateDeck edit = - { edit | changes = [] } + { edit | changes = [], saving = True } in ( { model | edit = model.edit |> Maybe.map updateDeck } , Api.save auth.token code patch (DeckSaved code >> Global.DecksMsg) @@ -110,7 +110,7 @@ update msg model = d updateDeck edit = - { edit | deck = deck.deck } + { edit | deck = deck.deck, saving = False } in ( { model | edit = model.edit |> Maybe.map updateDeck diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit.elm index f5bd5de..54e7a24 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit.elm @@ -9,13 +9,14 @@ import Cards.Call as Call exposing (Call(..)) import Cards.Card as GameCard import Cards.Deck exposing (Deck) import Cards.Response as Response exposing (Response(..)) +import FontAwesome.Attributes as Icon import FontAwesome.Icon as Icon import FontAwesome.Regular as RegularIcon import FontAwesome.Solid as Icon import Html exposing (Html) import Html.Attributes as HtmlA import Html.Events as HtmlE -import List.Extra as List +import Json.Decode as Json import ManyDecks.Messages as Global import ManyDecks.Pages.Decks.Deck as Deck import ManyDecks.Pages.Decks.Edit.CallEditor as CallEditor @@ -24,11 +25,13 @@ import ManyDecks.Pages.Decks.Edit.Import as Import import ManyDecks.Pages.Decks.Edit.Import.Model as Import import ManyDecks.Pages.Decks.Edit.Model exposing (..) import ManyDecks.Pages.Decks.Messages as Decks +import ManyDecks.Ports as Ports import Material.Button as Button import Material.Card as Card import Material.IconButton as IconButton import Material.Switch as Switch import Material.TextField as TextField +import Time init : Deck -> Model @@ -41,6 +44,7 @@ init deck = , errors = [] , deletionEnabled = False , importer = Nothing + , saving = False } @@ -93,8 +97,19 @@ update msg model = let m = endEditing model + + focus = + case cardEditor of + NameEditor _ _ -> + Cmd.none + + CallEditor _ _ _ -> + "call-input-ghost" |> Ports.focus + + ResponseEditor _ _ _ -> + "response-input" |> Ports.focus in - ( { m | editing = Just cardEditor }, Cmd.none ) + ( { m | editing = Just cardEditor }, focus ) EndEditing -> ( endEditing model, Cmd.none ) @@ -219,8 +234,23 @@ view code model = source = { name = model.deck.name, url = Nothing } + onKeyPress k = + if k == 13 then + EndEditing |> wrap + + else + Global.NoOp + viewResponse r = - [ Response.view (UpdateResponse >> Edit >> wrap |> GameCard.Mutable) GameCard.Face source r ] + let + mutable msg = + GameCard.Mutable msg + [ HtmlA.id "response-input" + , HtmlA.placeholder "type a response here" + , HtmlE.keyCode |> Json.map onKeyPress |> HtmlE.on "keydown" + ] + in + [ Response.view (UpdateResponse >> Edit >> wrap |> mutable) GameCard.Face source r ] editing editor = case editor of @@ -284,16 +314,19 @@ view code model = else Redo |> wrap |> Just - saveAction = - if List.isEmpty model.changes then - Nothing + ( saveText, saveIcon, saveAction ) = + if model.saving then + ( "Saving…", Icon.spinner |> Icon.viewStyled [ Icon.spin ], Nothing ) + + else if List.isEmpty model.changes then + ( "Saved", Icon.check |> Icon.viewIcon, Nothing ) else let - toPatch ( c, d ) = - Change.toPatch d [ c ] + action = + Decks.Save code (model.changes |> Change.manyToPatch) |> Global.DecksMsg |> Just in - Decks.Save code (model.changes |> List.map toPatch |> List.concat) |> Global.DecksMsg |> Just + ( "Save", Icon.save |> Icon.viewIcon, action ) actions = Html.div [ HtmlA.class "actions" ] @@ -303,24 +336,23 @@ view code model = "Import" (Icon.fileImport |> Icon.viewIcon |> Just) (True |> SetImportVisible |> wrap |> Just) - , Button.view - Button.Standard - Button.Padded - "Undo" - (Icon.undo |> Icon.viewIcon |> Just) - undoAction - , Button.view - Button.Standard - Button.Padded - "Redo" - (Icon.redo |> Icon.viewIcon |> Just) - redoAction - , Button.view - Button.Standard - Button.Padded - "Save" - (Icon.save |> Icon.viewIcon |> Just) - saveAction + , Html.div [ HtmlA.class "undo-redo" ] + [ Button.view + Button.Standard + Button.Padded + "Undo" + (Icon.undo |> Icon.viewIcon |> Just) + undoAction + , Button.view + Button.Standard + Button.Padded + "Redo" + (Icon.redo |> Icon.viewIcon |> Just) + redoAction + ] + , Html.div [ HtmlA.class "save" ] + [ Button.view Button.Standard Button.Padded saveText (saveIcon |> Just) saveAction + ] ] errorView = @@ -374,14 +406,26 @@ view code model = ] -subscriptions : Model -> Sub Global.Msg -subscriptions model = - case model.editing of - Just (CallEditor _ _ _) -> - CallEditor.subscriptions (UpdateCall >> Edit >> wrap) +subscriptions : Deck.Code -> Model -> Sub Global.Msg +subscriptions code model = + let + editor = + case model.editing of + Just (CallEditor _ _ _) -> + CallEditor.subscriptions (UpdateCall >> Edit >> wrap) - _ -> - Sub.none + _ -> + Sub.none + + autoSave = + if not model.saving && (model.changes |> List.isEmpty |> not) then + (Decks.Save code (model.changes |> Change.manyToPatch) |> Global.DecksMsg |> always) + |> Time.every 5000 + + else + Sub.none + in + Sub.batch [ editor, autoSave ] viewError : EditError -> Html Global.Msg @@ -475,7 +519,10 @@ endEditing model = Ok (Just c) -> model |> applyChange c - _ -> + Ok Nothing -> + { model | editing = Nothing } + + Err _ -> model Nothing -> diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm index 60e3b9e..a23ecfa 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit/CallEditor.elm @@ -90,7 +90,11 @@ update msg model = else model.selection in - ( { model | selection = selection }, Ports.setCallInputGhostSelection selection ) + if selection /= model.selection then + ( { model | selection = selection }, Ports.setCallInputGhostSelection selection ) + + else + ( model, Cmd.none ) StartSelection position -> let diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit/Change.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit/Change.elm index 2d932f8..9be5bd5 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit/Change.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit/Change.elm @@ -2,6 +2,7 @@ module ManyDecks.Pages.Decks.Edit.Change exposing ( apply , asContextForError , fromEditor + , manyToPatch , toPatch ) @@ -24,6 +25,15 @@ apply changes direction deck = applyPatchToDeck (changes |> toPatch direction) deck +manyToPatch : List ( Change, Direction ) -> Json.Patch +manyToPatch changes = + let + single ( c, d ) = + toPatch d [ c ] + in + changes |> List.map single |> List.concat + + toPatch : Direction -> List Change -> Json.Patch toPatch direction changes = let diff --git a/client/src/elm/ManyDecks/Pages/Decks/Edit/Model.elm b/client/src/elm/ManyDecks/Pages/Decks/Edit/Model.elm index cf397a2..13d0bd8 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/Edit/Model.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/Edit/Model.elm @@ -45,6 +45,7 @@ type alias Model = , errors : List EditError , deletionEnabled : Bool , importer : Maybe Import.Model + , saving : Bool } diff --git a/client/src/elm/ManyDecks/Pages/Decks/View.elm b/client/src/elm/ManyDecks/Pages/Decks/View.elm index 7400e48..5f83c14 100644 --- a/client/src/elm/ManyDecks/Pages/Decks/View.elm +++ b/client/src/elm/ManyDecks/Pages/Decks/View.elm @@ -50,34 +50,31 @@ view code auth model = (Decks.EditDeck code (Just model.deck) |> Global.DecksMsg |> Just) ownerActions _ = - Html.div [ HtmlA.class "owner-actions" ] - [ editButton ] + Html.div [ HtmlA.class "owner-actions" ] [ Html.div [] [], editButton ] in [ MaterialCard.view [ HtmlA.class "page view" ] [ Html.div [ HtmlA.class "header" ] - [ Html.div [ HtmlA.class "about" ] - [ Html.h1 [ HtmlA.class "title" ] [ code |> Deck.viewCode Global.Copy, model.deck.name |> Html.text ] - , Html.div [ HtmlA.class "details" ] - [ model.deck.author |> Maybe.map viewAuthor |> Maybe.withDefault (Html.text "") - , Html.span [ HtmlA.class "counts" ] - [ Html.span [ HtmlA.class "responses" ] - [ Html.a [ HtmlA.href "#responses" ] - [ RegIcon.square |> Icon.viewIcon - , Html.text "×" - , model.deck.responses |> List.length |> String.fromInt |> Html.text - ] + [ auth |> Maybe.map ownerActions |> Maybe.withDefault (Html.text "") + , Html.h1 [ HtmlA.class "title" ] [ code |> Deck.viewCode Global.Copy, model.deck.name |> Html.text ] + , Html.div [ HtmlA.class "details" ] + [ model.deck.author |> Maybe.map viewAuthor |> Maybe.withDefault (Html.text "") + , Html.span [ HtmlA.class "counts" ] + [ Html.span [ HtmlA.class "responses" ] + [ Html.a [ HtmlA.href "#responses" ] + [ RegIcon.square |> Icon.viewIcon + , Html.text "×" + , model.deck.responses |> List.length |> String.fromInt |> Html.text ] - , Html.span [ HtmlA.class "calls" ] - [ Html.a [ HtmlA.href "#calls" ] - [ Icon.square |> Icon.viewIcon - , Html.text "×" - , model.deck.calls |> List.length |> String.fromInt |> Html.text - ] + ] + , Html.span [ HtmlA.class "calls" ] + [ Html.a [ HtmlA.href "#calls" ] + [ Icon.square |> Icon.viewIcon + , Html.text "×" + , model.deck.calls |> List.length |> String.fromInt |> Html.text ] ] ] ] - , auth |> Maybe.map ownerActions |> Maybe.withDefault (Html.text "") ] , Html.p [ HtmlA.class "massive-decks-ad" ] [ Html.text "You can play with this deck on " diff --git a/client/src/elm/ManyDecks/Ports.elm b/client/src/elm/ManyDecks/Ports.elm index eb95a74..1a99ebb 100644 --- a/client/src/elm/ManyDecks/Ports.elm +++ b/client/src/elm/ManyDecks/Ports.elm @@ -23,6 +23,9 @@ port storeAuth : Maybe Auth.Auth -> Cmd msg port copy : String -> Cmd msg +port focus : String -> Cmd msg + + port setCallInputGhostSelection : Span -> Cmd msg diff --git a/client/src/elm/ManyDecks/index.d.ts b/client/src/elm/ManyDecks/index.d.ts index a3072a2..6c159f1 100644 --- a/client/src/elm/ManyDecks/index.d.ts +++ b/client/src/elm/ManyDecks/index.d.ts @@ -34,6 +34,7 @@ export namespace Elm { json5Decoded: OutboundPort; storeAuth: InboundPort; copy: InboundPort; + focus: InboundPort; setCallInputGhostSelection: InboundPort; getCallInputGhostSelection: OutboundPort; }; diff --git a/client/src/scss/_edit.scss b/client/src/scss/_edit.scss index ee88f0a..a3f244f 100644 --- a/client/src/scss/_edit.scss +++ b/client/src/scss/_edit.scss @@ -14,12 +14,16 @@ list-style: none; padding: 0; flex-basis: 50%; + flex-shrink: 1; + flex-grow: 1; li { padding: 0.25em; cursor: pointer; min-height: 1.2em; + word-break: break-all; + &:empty::after { display: block; content: "(Click To Edit)"; @@ -62,6 +66,16 @@ flex-wrap: wrap; justify-content: space-between; margin-bottom: 1em; + + .save { + display: flex; + justify-content: flex-end; + flex-basis: 8em; + } + + .undo-redo { + display: flex; + } } .delete { diff --git a/client/src/scss/_view.scss b/client/src/scss/_view.scss index bd51c3c..270da70 100644 --- a/client/src/scss/_view.scss +++ b/client/src/scss/_view.scss @@ -1,32 +1,40 @@ .view { .header { display: flex; - align-items: center; - justify-content: space-between; + flex-direction: column; + align-items: stretch; padding: 1em; .deck-code { margin-right: 0.5em; } - mwc-button { - align-self: inherit; + .title { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; } .owner-actions { display: flex; - flex-direction: column; - justify-content: center; + justify-content: space-between; + align-items: center; mwc-button { - margin: 0.5em 0; + align-self: inherit; } } .details { display: flex; + align-items: center; flex-wrap: wrap; + .author { + flex-grow: 1; + } + .counts { margin-left: 0.5em; diff --git a/client/src/ts/index.ts b/client/src/ts/index.ts index 8270663..1a922b9 100644 --- a/client/src/ts/index.ts +++ b/client/src/ts/index.ts @@ -57,8 +57,10 @@ const main = async () => { }); app.ports.copy.subscribe((id) => { - const textField = document.getElementById(id); - if (textField !== null && textField instanceof HTMLInputElement) { + const element = document.getElementById(id); + if (element !== null) { + const textField = element as HTMLInputElement; + textField.focus(); textField.select(); const value = textField.value; textField.setSelectionRange(0, value.length); @@ -70,6 +72,19 @@ const main = async () => { } }); + app.ports.focus.subscribe((id) => { + window.requestAnimationFrame(() => { + const element = document.getElementById(id); + if (element !== null) { + const textarea = element as HTMLTextAreaElement; + textarea.focus(); + // @ts-ignore + const last = element.textLength; + textarea.setSelectionRange(last, last); + } + }); + }); + app.ports.setCallInputGhostSelection.subscribe(({ start, end }) => { const element = document.getElementById("call-input-ghost"); if (element !== null) { @@ -83,8 +98,8 @@ const main = async () => { end: textarea.selectionEnd, }); }; - element.oninput = updateSelection; - element.onkeydown = updateSelection; + textarea.oninput = updateSelection; + textarea.onkeydown = updateSelection; } } }); diff --git a/server/src/ts/index.ts b/server/src/ts/index.ts index 8544df5..7fc4f66 100644 --- a/server/src/ts/index.ts +++ b/server/src/ts/index.ts @@ -18,11 +18,7 @@ import { default as Zip } from "express-easy-zip"; import * as Errors from "./errors"; import { default as Jwks } from "jwks-rsa"; import * as util from "util"; -import { - default as Jwt, - GetPublicKeyOrSecret, - VerifyErrors, -} from "jsonwebtoken"; +import { default as Jwt } from "jsonwebtoken"; import { AuthFailure } from "./errors"; interface TwitchClaims {