Initial version.
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
module Cards.Call exposing
|
||||
( Call
|
||||
, Line
|
||||
, decode
|
||||
, editor
|
||||
, editorToCall
|
||||
, encode
|
||||
, fromStrings
|
||||
, init
|
||||
, toString
|
||||
, type_
|
||||
, view
|
||||
)
|
||||
|
||||
import Cards.Call.Part as Part
|
||||
import Cards.Call.Part.Model as Part exposing (Part)
|
||||
import Cards.Call.Style as Style
|
||||
import Cards.Call.Transform as Transform
|
||||
import Cards.Card as Card
|
||||
import Cards.Response exposing (Response)
|
||||
import Cards.Type as Type exposing (Type)
|
||||
import Html exposing (Html)
|
||||
import Json.Decode as Json
|
||||
import Json.Encode
|
||||
import List.Extra as List
|
||||
import ManyDecks.Pages.Edit.CallEditor.Model as Editor exposing (Atom(..))
|
||||
|
||||
|
||||
type Call
|
||||
= Call (List Line)
|
||||
|
||||
|
||||
type alias Line =
|
||||
List Part
|
||||
|
||||
|
||||
type_ : Type Call
|
||||
type_ =
|
||||
Type.Call
|
||||
|
||||
|
||||
init : Call
|
||||
init =
|
||||
Call [ [ Part.Slot Transform.None Style.None ] ]
|
||||
|
||||
|
||||
view : List Response -> Card.Side -> Call -> Html msg
|
||||
view fill side (Call lines) =
|
||||
let
|
||||
folder row part ( f, column, soFar ) =
|
||||
let
|
||||
( rendered, rest ) =
|
||||
Part.view f part
|
||||
in
|
||||
( rest, column + 1, rendered :: soFar )
|
||||
|
||||
lineFolder line ( f, row, soFar ) =
|
||||
let
|
||||
( rest, _, lineContent ) =
|
||||
line |> List.foldr (folder row) ( f, 0, [] )
|
||||
in
|
||||
( rest, row + 1, Html.p [] lineContent :: soFar )
|
||||
|
||||
( _, _, content ) =
|
||||
lines |> List.foldr lineFolder ( fill, 0, [] )
|
||||
in
|
||||
Card.view type_ Card.Immutable content side
|
||||
|
||||
|
||||
toString : String -> List Response -> Call -> String
|
||||
toString lineBreak _ (Call lines) =
|
||||
let
|
||||
toBasic part =
|
||||
case part of
|
||||
Part.Slot _ _ ->
|
||||
"_"
|
||||
|
||||
Part.Text text _ ->
|
||||
text
|
||||
in
|
||||
lines |> List.map (List.map toBasic) |> List.intersperse [ lineBreak ] |> List.concat |> String.concat
|
||||
|
||||
|
||||
fromStrings : List String -> Call
|
||||
fromStrings strings =
|
||||
strings
|
||||
|> List.map (\t -> Part.Text t Style.None)
|
||||
|> List.intersperse (Part.Slot Transform.None Style.None)
|
||||
|> (\p -> Call [ p ])
|
||||
|
||||
|
||||
decode : Json.Decoder Call
|
||||
decode =
|
||||
let
|
||||
line =
|
||||
Json.list Part.decode
|
||||
in
|
||||
Json.list line |> Json.map Call
|
||||
|
||||
|
||||
encode : Call -> Json.Value
|
||||
encode (Call call) =
|
||||
let
|
||||
line =
|
||||
Json.Encode.list Part.encode
|
||||
in
|
||||
call |> Json.Encode.list line
|
||||
|
||||
|
||||
editor : Call -> Editor.Model
|
||||
editor (Call lines) =
|
||||
{ atoms = lines |> List.map (List.concatMap partToAtoms) |> List.intersperse [ NewLine ] |> List.concat
|
||||
, selection = Nothing
|
||||
, selecting = Nothing
|
||||
, moving = Nothing
|
||||
, hover = Nothing
|
||||
, cursor = 0
|
||||
, styled = []
|
||||
, control = False
|
||||
}
|
||||
|
||||
|
||||
partToAtoms : Part -> List Editor.Atom
|
||||
partToAtoms part =
|
||||
case part of
|
||||
Part.Text text style ->
|
||||
text |> String.toList |> List.map Editor.Letter
|
||||
|
||||
Part.Slot transform style ->
|
||||
[ Editor.Slot transform style ]
|
||||
|
||||
|
||||
editorToCall : Editor.Model -> Result String Call
|
||||
editorToCall { atoms } =
|
||||
let
|
||||
parts =
|
||||
atoms |> List.groupWhile (\a b -> b /= NewLine) |> List.map atomsToParts
|
||||
in
|
||||
if parts |> List.any (List.any Part.isSlot) then
|
||||
parts |> Call |> Ok
|
||||
|
||||
else
|
||||
"Calls must contain at least one slot." |> Err
|
||||
|
||||
|
||||
atomsToParts : ( Editor.Atom, List Editor.Atom ) -> List Part
|
||||
atomsToParts ( f, r ) =
|
||||
let
|
||||
atoms =
|
||||
f :: r
|
||||
|
||||
group a b =
|
||||
case a of
|
||||
Letter _ ->
|
||||
case b of
|
||||
Letter _ ->
|
||||
True
|
||||
|
||||
_ ->
|
||||
False
|
||||
|
||||
_ ->
|
||||
False
|
||||
|
||||
toChar atom =
|
||||
case atom of
|
||||
Letter char ->
|
||||
Just char
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
toPart ( first, rest ) =
|
||||
case first of
|
||||
Letter _ ->
|
||||
(first :: rest)
|
||||
|> List.filterMap toChar
|
||||
|> String.fromList
|
||||
|> (\t -> Part.Text t Style.None |> Just)
|
||||
|
||||
Slot transform style ->
|
||||
Part.Slot transform style |> Just
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
in
|
||||
atoms |> List.groupWhile group |> List.filterMap toPart
|
||||
@@ -0,0 +1,115 @@
|
||||
module Cards.Call.Part exposing
|
||||
( decode
|
||||
, encode
|
||||
, isSlot
|
||||
, view
|
||||
)
|
||||
|
||||
import Cards.Call.Part.Model exposing (Part(..))
|
||||
import Cards.Call.Style as Style exposing (Style)
|
||||
import Cards.Call.Transform as Transform exposing (Transform)
|
||||
import Cards.Response as Response exposing (Response(..))
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Json.Decode as Json
|
||||
import Json.Decode.Pipeline as Json
|
||||
import Json.Encode
|
||||
|
||||
|
||||
view : List Response -> Part -> ( Html msg, List Response )
|
||||
view fill part =
|
||||
let
|
||||
splitWords string =
|
||||
case String.uncons string of
|
||||
Nothing ->
|
||||
[]
|
||||
|
||||
Just ( first, rest ) ->
|
||||
case first of
|
||||
' ' ->
|
||||
String.fromChar first :: splitWords rest
|
||||
|
||||
other ->
|
||||
case splitWords rest of
|
||||
[] ->
|
||||
[ String.fromChar other ]
|
||||
|
||||
head :: tail ->
|
||||
String.cons other head :: tail
|
||||
|
||||
viewText text =
|
||||
text |> splitWords |> List.map (\t -> Html.span [] [ Html.text t ])
|
||||
in
|
||||
case part of
|
||||
Text text style ->
|
||||
( Style.toNode style [ HtmlA.class "text" ] (viewText text), fill )
|
||||
|
||||
Slot transform style ->
|
||||
let
|
||||
( emptyAttr, text, restOfFill ) =
|
||||
case fill of
|
||||
r :: rest ->
|
||||
( [], r |> Response.toString |> viewText, rest )
|
||||
|
||||
[] ->
|
||||
( [ HtmlA.class "empty" ], [], [] )
|
||||
|
||||
attrs =
|
||||
List.concat [ [ HtmlA.class "slot" ], emptyAttr, Transform.toAttributes transform ]
|
||||
in
|
||||
( Style.toNode style attrs text, restOfFill )
|
||||
|
||||
|
||||
decode : Json.Decoder Part
|
||||
decode =
|
||||
let
|
||||
slot =
|
||||
Json.succeed Slot
|
||||
|> Json.optional "transform" Transform.decode Transform.None
|
||||
|> Json.optional "style" Style.decode Style.None
|
||||
|
||||
styled =
|
||||
Json.succeed Text
|
||||
|> Json.required "text" Json.string
|
||||
|> Json.optional "style" Style.decode Style.None
|
||||
in
|
||||
Json.oneOf
|
||||
[ Json.string |> Json.map (\t -> Text t Style.None)
|
||||
, styled
|
||||
, slot
|
||||
]
|
||||
|
||||
|
||||
encode : Part -> Json.Value
|
||||
encode part =
|
||||
let
|
||||
maybeField field =
|
||||
case field of
|
||||
( n, Just v ) ->
|
||||
Just ( n, v )
|
||||
|
||||
( _, Nothing ) ->
|
||||
Nothing
|
||||
|
||||
maybeObject =
|
||||
List.filterMap maybeField >> Json.Encode.object
|
||||
in
|
||||
case part of
|
||||
Text text Style.None ->
|
||||
text |> Json.Encode.string
|
||||
|
||||
Text text s ->
|
||||
maybeObject [ ( "text", text |> Json.Encode.string |> Just ), ( "style", s |> Style.encode ) ]
|
||||
|
||||
Slot t s ->
|
||||
maybeObject [ ( "transform", t |> Transform.encode ), ( "style", s |> Style.encode ) ]
|
||||
|
||||
|
||||
isSlot : Part -> Bool
|
||||
isSlot part =
|
||||
case part of
|
||||
Text string style ->
|
||||
False
|
||||
|
||||
Slot transform style ->
|
||||
True
|
||||
@@ -0,0 +1,9 @@
|
||||
module Cards.Call.Part.Model exposing (..)
|
||||
|
||||
import Cards.Call.Style exposing (Style)
|
||||
import Cards.Call.Transform exposing (Transform)
|
||||
|
||||
|
||||
type Part
|
||||
= Text String Style
|
||||
| Slot Transform Style
|
||||
@@ -0,0 +1,53 @@
|
||||
module Cards.Call.Style exposing
|
||||
( Style(..)
|
||||
, decode
|
||||
, encode
|
||||
, toNode
|
||||
)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Json.Decode as Json
|
||||
import Json.Encode
|
||||
|
||||
|
||||
type Style
|
||||
= None
|
||||
| Em
|
||||
|
||||
|
||||
toNode : Style -> (List (Html.Attribute msg) -> List (Html msg) -> Html msg)
|
||||
toNode style =
|
||||
case style of
|
||||
None ->
|
||||
Html.span
|
||||
|
||||
Em ->
|
||||
Html.em
|
||||
|
||||
|
||||
decode : Json.Decoder Style
|
||||
decode =
|
||||
let
|
||||
byName name =
|
||||
case name of
|
||||
"Em" ->
|
||||
Json.succeed Em
|
||||
|
||||
_ ->
|
||||
"Unknown style: " ++ name |> Json.fail
|
||||
in
|
||||
Json.string |> Json.andThen byName
|
||||
|
||||
|
||||
encode : Style -> Maybe Json.Value
|
||||
encode s =
|
||||
let
|
||||
name =
|
||||
case s of
|
||||
None ->
|
||||
Nothing
|
||||
|
||||
Em ->
|
||||
Just "Em"
|
||||
in
|
||||
name |> Maybe.map Json.Encode.string
|
||||
@@ -0,0 +1,64 @@
|
||||
module Cards.Call.Transform exposing
|
||||
( Transform(..)
|
||||
, decode
|
||||
, encode
|
||||
, toAttributes
|
||||
)
|
||||
|
||||
import Html
|
||||
import Html.Attributes as HtmlA
|
||||
import Json.Decode as Json
|
||||
import Json.Encode
|
||||
|
||||
|
||||
type Transform
|
||||
= None
|
||||
| UpperCase
|
||||
| Capitalize
|
||||
|
||||
|
||||
toAttributes : Transform -> List (Html.Attribute msg)
|
||||
toAttributes style =
|
||||
case style of
|
||||
None ->
|
||||
[]
|
||||
|
||||
UpperCase ->
|
||||
[ HtmlA.class "upper-case" ]
|
||||
|
||||
Capitalize ->
|
||||
[ HtmlA.class "capitalize" ]
|
||||
|
||||
|
||||
decode : Json.Decoder Transform
|
||||
decode =
|
||||
let
|
||||
byName name =
|
||||
case name of
|
||||
"UpperCase" ->
|
||||
Json.succeed UpperCase
|
||||
|
||||
"Capitalize" ->
|
||||
Json.succeed Capitalize
|
||||
|
||||
_ ->
|
||||
"Unknown transform: " ++ name |> Json.fail
|
||||
in
|
||||
Json.string |> Json.andThen byName
|
||||
|
||||
|
||||
encode : Transform -> Maybe Json.Value
|
||||
encode t =
|
||||
let
|
||||
name =
|
||||
case t of
|
||||
None ->
|
||||
Nothing
|
||||
|
||||
Capitalize ->
|
||||
Just "Capitalize"
|
||||
|
||||
UpperCase ->
|
||||
Just "UpperCase"
|
||||
in
|
||||
name |> Maybe.map Json.Encode.string
|
||||
@@ -0,0 +1,74 @@
|
||||
module Cards.Card exposing
|
||||
( Mutability(..)
|
||||
, Side(..)
|
||||
, view
|
||||
)
|
||||
|
||||
import Cards.Type as Type exposing (Type)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
|
||||
|
||||
type Mutability value msg
|
||||
= Immutable
|
||||
| Mutable (value -> msg)
|
||||
|
||||
|
||||
type Side
|
||||
= Face
|
||||
| Reverse
|
||||
|
||||
|
||||
view : Type value -> Mutability value msg -> List (Html msg) -> Side -> Html msg
|
||||
view type_ mutability content visibleSide =
|
||||
let
|
||||
mutabilityClass =
|
||||
case mutability of
|
||||
Immutable ->
|
||||
"immutable"
|
||||
|
||||
Mutable _ ->
|
||||
"mutable"
|
||||
|
||||
typeClass =
|
||||
case type_ of
|
||||
Type.Call ->
|
||||
"call"
|
||||
|
||||
Type.Response ->
|
||||
"response"
|
||||
|
||||
visibleSideClass =
|
||||
case visibleSide of
|
||||
Face ->
|
||||
"face-up"
|
||||
|
||||
Reverse ->
|
||||
"face-down"
|
||||
|
||||
viewSide side primary secondary =
|
||||
let
|
||||
sideClass =
|
||||
case side of
|
||||
Face ->
|
||||
"face"
|
||||
|
||||
Reverse ->
|
||||
"reverse"
|
||||
in
|
||||
Html.div [ HtmlA.classList [ ( "side", True ), ( sideClass, True ) ] ]
|
||||
[ Html.div [ HtmlA.class "primary-content" ] primary
|
||||
, Html.div [ HtmlA.class "secondary-content" ] secondary
|
||||
]
|
||||
in
|
||||
Html.div
|
||||
[ HtmlA.classList
|
||||
[ ( "game-card", True )
|
||||
, ( typeClass, True )
|
||||
, ( visibleSideClass, True )
|
||||
, ( mutabilityClass, True )
|
||||
]
|
||||
]
|
||||
[ viewSide Reverse [ Html.text "Massive", Html.text "Decks" ] []
|
||||
, viewSide Face content []
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
module Cards.Deck exposing
|
||||
( Deck
|
||||
, decode
|
||||
, empty
|
||||
, encode
|
||||
)
|
||||
|
||||
import Cards.Call as Call exposing (Call)
|
||||
import Cards.Response as Response exposing (Response)
|
||||
import Json.Decode as Json
|
||||
import Json.Decode.Pipeline as Json
|
||||
import Json.Encode
|
||||
|
||||
|
||||
type alias Deck =
|
||||
{ name : String
|
||||
, language : Maybe String
|
||||
, author : Maybe String
|
||||
, calls : List Call
|
||||
, responses : List Response
|
||||
}
|
||||
|
||||
|
||||
empty : Deck
|
||||
empty =
|
||||
{ name = "New Deck"
|
||||
, language = Nothing
|
||||
, author = Nothing
|
||||
, calls = []
|
||||
, responses = []
|
||||
}
|
||||
|
||||
|
||||
encode : Deck -> Json.Value
|
||||
encode deck =
|
||||
let
|
||||
maybeField field =
|
||||
case field of
|
||||
( n, Just v ) ->
|
||||
Just ( n, v )
|
||||
|
||||
( _, Nothing ) ->
|
||||
Nothing
|
||||
|
||||
maybeObject =
|
||||
List.filterMap maybeField >> Json.Encode.object
|
||||
in
|
||||
maybeObject
|
||||
[ ( "name", Json.Encode.string deck.name |> Just )
|
||||
, ( "language", deck.language |> Maybe.map Json.Encode.string )
|
||||
, ( "author", deck.author |> Maybe.map Json.Encode.string )
|
||||
, ( "calls", deck.calls |> Json.Encode.list Call.encode |> Just )
|
||||
, ( "responses", deck.responses |> Json.Encode.list Response.encode |> Just )
|
||||
]
|
||||
|
||||
|
||||
decode : Json.Decoder Deck
|
||||
decode =
|
||||
Json.succeed Deck
|
||||
|> Json.required "name" Json.string
|
||||
|> Json.optional "language" (Json.string |> Json.map Just) Nothing
|
||||
|> Json.optional "author" (Json.string |> Json.map Just) Nothing
|
||||
|> Json.required "calls" (Json.list Call.decode)
|
||||
|> Json.required "responses" (Json.list Response.decode)
|
||||
@@ -0,0 +1,71 @@
|
||||
module Cards.Response exposing
|
||||
( Response
|
||||
, decode
|
||||
, encode
|
||||
, fromString
|
||||
, init
|
||||
, toString
|
||||
, view
|
||||
)
|
||||
|
||||
import Cards.Card as Card
|
||||
import Cards.Type as Type exposing (Type)
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Html.Events as HtmlE
|
||||
import Json.Decode as Json
|
||||
import Json.Encode
|
||||
|
||||
|
||||
type Response
|
||||
= Response String
|
||||
|
||||
|
||||
type_ : Type Response
|
||||
type_ =
|
||||
Type.Response
|
||||
|
||||
|
||||
init : Response
|
||||
init =
|
||||
Response ""
|
||||
|
||||
|
||||
view : Card.Mutability Response msg -> Card.Side -> Response -> Html msg
|
||||
view mutability side (Response text) =
|
||||
let
|
||||
content =
|
||||
case mutability of
|
||||
Card.Immutable ->
|
||||
[ Html.text text ]
|
||||
|
||||
Card.Mutable update ->
|
||||
[ Html.textarea
|
||||
[ HtmlA.value text
|
||||
, HtmlE.onInput (Response >> update)
|
||||
, HtmlA.placeholder "type a response here"
|
||||
]
|
||||
[]
|
||||
]
|
||||
in
|
||||
Card.view type_ mutability content side
|
||||
|
||||
|
||||
toString : Response -> String
|
||||
toString (Response text) =
|
||||
text
|
||||
|
||||
|
||||
fromString : String -> Response
|
||||
fromString text =
|
||||
Response text
|
||||
|
||||
|
||||
encode : Response -> Json.Value
|
||||
encode (Response response) =
|
||||
response |> Json.Encode.string
|
||||
|
||||
|
||||
decode : Json.Decoder Response
|
||||
decode =
|
||||
Json.string |> Json.map Response
|
||||
@@ -0,0 +1,6 @@
|
||||
module Cards.Type exposing (Type(..))
|
||||
|
||||
|
||||
type Type value
|
||||
= Call
|
||||
| Response
|
||||
@@ -0,0 +1,391 @@
|
||||
module ManyDecks exposing (..)
|
||||
|
||||
import Browser
|
||||
import Browser.Navigation as Navigation
|
||||
import Cards.Deck as Deck
|
||||
import File
|
||||
import File.Download as File
|
||||
import File.Select as File
|
||||
import FontAwesome.Icon as Icon
|
||||
import FontAwesome.Solid as Icon
|
||||
import FontAwesome.Styles as Icon
|
||||
import Html
|
||||
import Html.Attributes as HtmlA
|
||||
import Http
|
||||
import Json.Decode as Json
|
||||
import ManyDecks.Auth exposing (Auth)
|
||||
import ManyDecks.Error as Error exposing (Error)
|
||||
import ManyDecks.Google as Google
|
||||
import ManyDecks.Messages exposing (Msg(..))
|
||||
import ManyDecks.Pages.Decks as Decks
|
||||
import ManyDecks.Pages.Decks.Model as Decks
|
||||
import ManyDecks.Pages.Edit as Edit
|
||||
import ManyDecks.Pages.Edit.Model as Edit
|
||||
import ManyDecks.Pages.Login as Login
|
||||
import ManyDecks.Pages.Profile as Profile
|
||||
import ManyDecks.Pages.Profile.Model as Profile
|
||||
import ManyDecks.Ports as Ports
|
||||
import Material.Button as Button
|
||||
import Task
|
||||
import Url exposing (Url)
|
||||
|
||||
|
||||
type alias Flags =
|
||||
{ auth : Maybe Auth }
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ core : Maybe CoreModel
|
||||
, error : Maybe Error
|
||||
}
|
||||
|
||||
|
||||
type alias CoreModel =
|
||||
{ auth : Auth
|
||||
, decks : Maybe (List Decks.CodeAndSummary)
|
||||
, profile : Profile.Model
|
||||
, edit : Maybe Edit.Model
|
||||
}
|
||||
|
||||
|
||||
initCore auth =
|
||||
{ auth = auth, decks = Nothing, profile = Profile.init auth, edit = Nothing }
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = subscriptions
|
||||
, onUrlRequest = onUrlRequest
|
||||
, onUrlChange = onUrlChange
|
||||
}
|
||||
|
||||
|
||||
init : Flags -> Url -> Navigation.Key -> ( Model, Cmd Msg )
|
||||
init flags url key =
|
||||
( { core = flags.auth |> Maybe.map initCore, error = Nothing }
|
||||
, case flags.auth of
|
||||
Just auth ->
|
||||
Decks.getDecks auth.token decksFromResult
|
||||
|
||||
Nothing ->
|
||||
Cmd.none
|
||||
)
|
||||
|
||||
|
||||
subscriptions : Model -> Sub Msg
|
||||
subscriptions model =
|
||||
let
|
||||
googleAuthResultToMessage value =
|
||||
case value |> Json.decodeValue Google.authResult of
|
||||
Ok (Ok code) ->
|
||||
GoogleAuthResult code
|
||||
|
||||
Ok (Err error) ->
|
||||
error |> Error
|
||||
|
||||
Err error ->
|
||||
error |> Json.errorToString |> Error
|
||||
|
||||
json5DecodedToMessage value =
|
||||
case value |> Json.decodeValue Deck.decode of
|
||||
Ok deck ->
|
||||
NewDeck deck
|
||||
|
||||
Err error ->
|
||||
error |> Json.errorToString |> Error
|
||||
in
|
||||
Sub.batch
|
||||
[ Ports.googleAuthResult googleAuthResultToMessage
|
||||
, Ports.json5Decoded json5DecodedToMessage
|
||||
, model.core |> Maybe.andThen .edit |> Maybe.map (Edit.subscriptions EditMsg) |> Maybe.withDefault Sub.none
|
||||
]
|
||||
|
||||
|
||||
onUrlRequest : Browser.UrlRequest -> Msg
|
||||
onUrlRequest urlRequest =
|
||||
NoOp
|
||||
|
||||
|
||||
onUrlChange : Url -> Msg
|
||||
onUrlChange url =
|
||||
NoOp
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
SetError error ->
|
||||
( { model | error = Just error }, Cmd.none )
|
||||
|
||||
TryGoogleAuth ->
|
||||
( model, Ports.tryGoogleAuth () )
|
||||
|
||||
GoogleAuthResult code ->
|
||||
let
|
||||
authFromGoogleCode result =
|
||||
case result of
|
||||
Ok token ->
|
||||
MdAuthResult token
|
||||
|
||||
Err error ->
|
||||
error |> Error.Http |> SetError
|
||||
in
|
||||
( model, Google.signIn code authFromGoogleCode )
|
||||
|
||||
MdAuthResult auth ->
|
||||
( { model | core = auth |> initCore |> Just }
|
||||
, Cmd.batch
|
||||
[ Decks.getDecks auth.token decksFromResult
|
||||
, auth |> Just |> Ports.storeAuth
|
||||
]
|
||||
)
|
||||
|
||||
ReceiveDecks decks ->
|
||||
let
|
||||
add m =
|
||||
{ m | decks = Just decks }
|
||||
in
|
||||
( { model | core = model.core |> Maybe.map add }, Cmd.none )
|
||||
|
||||
UploadDeck ->
|
||||
( model, File.file [ ".deck.json5" ] UploadedDeck )
|
||||
|
||||
UploadedDeck file ->
|
||||
( model, file |> File.toString |> Task.perform Json5Parse )
|
||||
|
||||
Json5Parse raw ->
|
||||
( model, Ports.json5Decode raw )
|
||||
|
||||
NewDeck d ->
|
||||
let
|
||||
token =
|
||||
model.core |> Maybe.map (.auth >> .token) |> Maybe.withDefault ""
|
||||
|
||||
newDeck result =
|
||||
case result of
|
||||
Ok code ->
|
||||
EditDeck code (Just d) True
|
||||
|
||||
Err error ->
|
||||
error |> Error.Http |> SetError
|
||||
in
|
||||
( model, Decks.createDeck token d newDeck )
|
||||
|
||||
EditDeck code deck needsToBeAdded ->
|
||||
case deck of
|
||||
Just d ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
let
|
||||
decks =
|
||||
if needsToBeAdded then
|
||||
let
|
||||
summary =
|
||||
{ details =
|
||||
{ name = d.name
|
||||
, author = d.author |> Maybe.withDefault m.auth.name
|
||||
, language = d.language
|
||||
}
|
||||
, calls = d.calls |> List.length
|
||||
, responses = d.responses |> List.length
|
||||
, version = 0
|
||||
}
|
||||
in
|
||||
m.decks |> Maybe.map (\ds -> ds ++ [ { code = code, summary = summary } ])
|
||||
|
||||
else
|
||||
m.decks
|
||||
in
|
||||
( { model | core = Just { m | edit = Edit.init code d |> Just, decks = decks } }, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
let
|
||||
handle result =
|
||||
case result of
|
||||
Ok d ->
|
||||
EditDeck code (Just d) needsToBeAdded
|
||||
|
||||
Err error ->
|
||||
error |> Error.Http |> SetError
|
||||
in
|
||||
( model, Decks.getDeck code handle )
|
||||
|
||||
BackFromEdit ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
( { model | core = Just { m | edit = Nothing } }, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Delete code ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
let
|
||||
newDecks =
|
||||
m.decks |> Maybe.map (List.filter (\d -> d.code /= code))
|
||||
in
|
||||
( { model | core = Just { m | decks = newDecks, edit = Nothing } }
|
||||
, Decks.deleteDeck m.auth.token code
|
||||
)
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Save code patch ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
let
|
||||
handle result =
|
||||
case result of
|
||||
Ok () ->
|
||||
BackFromEdit
|
||||
|
||||
Err error ->
|
||||
error |> Error.Http |> SetError
|
||||
in
|
||||
( model, Decks.save m.auth.token code patch handle )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Copy id ->
|
||||
( model, Ports.copy id )
|
||||
|
||||
ProfileMsg profileMsg ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
let
|
||||
( newP, cmd ) =
|
||||
m.profile |> Profile.update SignOut UpdateAuth ProfileMsg m.auth.token profileMsg
|
||||
in
|
||||
( { model | core = Just { m | profile = newP } }, cmd )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
EditMsg editMsg ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
case m.edit of
|
||||
Just e ->
|
||||
let
|
||||
( newE, cmd ) =
|
||||
Edit.update editMsg e
|
||||
in
|
||||
( { model | core = Just { m | edit = Just newE } }, cmd )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Backup ->
|
||||
let
|
||||
handleResult result =
|
||||
case result of
|
||||
Ok file ->
|
||||
DownloadBytes file
|
||||
|
||||
Err error ->
|
||||
error |> Error.Http |> SetError
|
||||
|
||||
cmd =
|
||||
model.core |> Maybe.map (\m -> Decks.backup m.auth.token handleResult) |> Maybe.withDefault Cmd.none
|
||||
in
|
||||
( model, cmd )
|
||||
|
||||
DownloadBytes bytes ->
|
||||
( model, File.bytes "backup.zip" "application/zip" bytes )
|
||||
|
||||
UpdateAuth newAuth ->
|
||||
case model.core of
|
||||
Just m ->
|
||||
( { model | core = Just { m | auth = newAuth } }, newAuth |> Just |> Ports.storeAuth )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SignOut ->
|
||||
( { model | core = Nothing }, Ports.storeAuth Nothing )
|
||||
|
||||
Error _ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
NoOp ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
view : Model -> Browser.Document Msg
|
||||
view model =
|
||||
let
|
||||
error =
|
||||
Error.view model.error
|
||||
|
||||
body =
|
||||
case model.core of
|
||||
Nothing ->
|
||||
[ Html.div [ HtmlA.class "content" ] [ Login.view ] ]
|
||||
|
||||
Just m ->
|
||||
let
|
||||
content =
|
||||
if m.profile.viewing then
|
||||
Profile.view ProfileMsg Backup m.auth m.profile
|
||||
|
||||
else
|
||||
case m.edit of
|
||||
Just edit ->
|
||||
Edit.view BackFromEdit Save Delete EditMsg edit
|
||||
|
||||
Nothing ->
|
||||
Decks.view m.decks
|
||||
in
|
||||
[ generalNav m
|
||||
, Html.div [ HtmlA.class "content" ] content
|
||||
]
|
||||
in
|
||||
{ title = "Many Decks"
|
||||
, body = Icon.css :: error :: body
|
||||
}
|
||||
|
||||
|
||||
generalNav model =
|
||||
let
|
||||
viewProfile =
|
||||
model.profile.viewing |> not |> Profile.SetViewingProfile |> ProfileMsg
|
||||
in
|
||||
Html.nav []
|
||||
[ Html.div [ HtmlA.id "sign-out" ]
|
||||
[ Button.view Button.Standard
|
||||
Button.Padded
|
||||
"Sign Out"
|
||||
(Icon.signOutAlt |> Icon.viewIcon |> Just)
|
||||
(Just SignOut)
|
||||
]
|
||||
, Html.div [ HtmlA.id "view-profile" ]
|
||||
[ Button.view Button.Standard
|
||||
Button.Padded
|
||||
(model.auth.name ++ "'s Profile")
|
||||
(Icon.userCircle |> Icon.viewIcon |> Just)
|
||||
(Just viewProfile)
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
decksFromResult : Result Http.Error (List Decks.CodeAndSummary) -> Msg
|
||||
decksFromResult result =
|
||||
case result of
|
||||
Ok token ->
|
||||
ReceiveDecks token
|
||||
|
||||
Err error ->
|
||||
error |> Error.Http |> SetError
|
||||
@@ -0,0 +1,25 @@
|
||||
module ManyDecks.Auth exposing
|
||||
( Auth
|
||||
, Token
|
||||
, decoder
|
||||
)
|
||||
|
||||
import Json.Decode as Json
|
||||
import Json.Decode.Pipeline as Json
|
||||
|
||||
|
||||
type alias Token =
|
||||
String
|
||||
|
||||
|
||||
type alias Auth =
|
||||
{ token : Token
|
||||
, name : String
|
||||
}
|
||||
|
||||
|
||||
decoder : Json.Decoder Auth
|
||||
decoder =
|
||||
Json.succeed Auth
|
||||
|> Json.required "token" Json.string
|
||||
|> Json.required "name" Json.string
|
||||
@@ -0,0 +1,61 @@
|
||||
module ManyDecks.Error exposing (..)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Http
|
||||
import Json.Decode as Json
|
||||
import Material.Card as Card
|
||||
|
||||
|
||||
type Error
|
||||
= Http Http.Error
|
||||
| Json Json.Error
|
||||
|
||||
|
||||
view : Maybe Error -> Html msg
|
||||
view error =
|
||||
case error of
|
||||
Just e ->
|
||||
Html.div [ HtmlA.class "core-error" ]
|
||||
[ Card.view []
|
||||
[ Html.p [] [ Html.text "Sorry, there appears to have been a problem, please try refreshing the page." ]
|
||||
, Html.p [] [ e |> message |> Html.text ]
|
||||
]
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
Html.text ""
|
||||
|
||||
|
||||
message : Error -> String
|
||||
message error =
|
||||
case error of
|
||||
Http e ->
|
||||
case e of
|
||||
Http.BadUrl url ->
|
||||
"Application bug: Tried to access “" ++ url ++ "” which isn't a valid URL."
|
||||
|
||||
Http.Timeout ->
|
||||
"Timed out trying to connect to the server, it is probably down. Try again after a short delay."
|
||||
|
||||
Http.NetworkError ->
|
||||
"Could not connect to the server. Please check your internet connection and try again."
|
||||
|
||||
Http.BadStatus status ->
|
||||
if status == 504 || status == 502 then
|
||||
"The server appears to be down. Try again after a short delay."
|
||||
|
||||
else if status >= 400 && status < 500 then
|
||||
"The server rejected that, please check for problems."
|
||||
|
||||
else if status >= 500 && status < 600 then
|
||||
"There was a problem with the server."
|
||||
|
||||
else
|
||||
"The server returned an expected response."
|
||||
|
||||
Http.BadBody description ->
|
||||
"We got a response we didn't expect from the server: " ++ description
|
||||
|
||||
Json e ->
|
||||
e |> Json.errorToString
|
||||
@@ -0,0 +1,32 @@
|
||||
module ManyDecks.Google exposing (..)
|
||||
|
||||
import Http
|
||||
import Json.Decode as Json
|
||||
import Json.Decode.Pipeline as Json
|
||||
import Json.Encode
|
||||
import ManyDecks.Auth as Auth exposing (Auth)
|
||||
|
||||
|
||||
authResult : Json.Decoder (Result String String)
|
||||
authResult =
|
||||
let
|
||||
codeOrError code =
|
||||
case code of
|
||||
Just c ->
|
||||
Json.succeed Ok
|
||||
|> Json.required "code" Json.string
|
||||
|
||||
Nothing ->
|
||||
Json.succeed Err
|
||||
|> Json.required "error" Json.string
|
||||
in
|
||||
Json.maybe (Json.field "code" Json.string) |> Json.andThen codeOrError
|
||||
|
||||
|
||||
signIn : String -> (Result Http.Error Auth -> msg) -> Cmd msg
|
||||
signIn code toMsg =
|
||||
Http.post
|
||||
{ url = "/api/users"
|
||||
, body = [ ( "google", code |> Json.Encode.string ) ] |> Json.Encode.object |> Http.jsonBody
|
||||
, expect = Http.expectJson toMsg Auth.decoder
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
module ManyDecks.Messages exposing (Msg(..))
|
||||
|
||||
import Bytes exposing (Bytes)
|
||||
import Cards.Deck as Deck
|
||||
import File exposing (File)
|
||||
import Json.Patch.Invertible as Json
|
||||
import ManyDecks.Auth exposing (Auth)
|
||||
import ManyDecks.Error exposing (Error)
|
||||
import ManyDecks.Pages.Decks.Deck as Deck
|
||||
import ManyDecks.Pages.Decks.Model as Decks
|
||||
import ManyDecks.Pages.Edit.Model as Edit exposing (Change)
|
||||
import ManyDecks.Pages.Profile.Model as Profile
|
||||
|
||||
|
||||
type Msg
|
||||
= NoOp
|
||||
| SetError Error
|
||||
| TryGoogleAuth
|
||||
| GoogleAuthResult String
|
||||
| MdAuthResult Auth
|
||||
| ReceiveDecks (List Decks.CodeAndSummary)
|
||||
| UploadDeck
|
||||
| UploadedDeck File
|
||||
| Json5Parse String
|
||||
| NewDeck Deck.Deck
|
||||
| EditDeck Deck.Code (Maybe Deck.Deck) Bool
|
||||
| BackFromEdit
|
||||
| Copy String
|
||||
| ProfileMsg Profile.Msg
|
||||
| EditMsg Edit.Msg
|
||||
| Backup
|
||||
| DownloadBytes Bytes
|
||||
| UpdateAuth Auth
|
||||
| Delete Deck.Code
|
||||
| Save Deck.Code Json.Patch
|
||||
| SignOut
|
||||
| Error String
|
||||
@@ -0,0 +1,164 @@
|
||||
module ManyDecks.Pages.Decks exposing (..)
|
||||
|
||||
import Bytes exposing (Bytes)
|
||||
import Cards.Deck as Deck exposing (Deck)
|
||||
import FontAwesome.Attributes as Icon
|
||||
import FontAwesome.Icon as Icon
|
||||
import FontAwesome.Solid as Icon
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Html.Events as HtmlE
|
||||
import Html.Keyed as HtmlK
|
||||
import Http
|
||||
import Json.Decode as Json
|
||||
import Json.Encode
|
||||
import Json.Patch
|
||||
import Json.Patch.Invertible as Json
|
||||
import ManyDecks.Messages exposing (Msg(..))
|
||||
import ManyDecks.Pages.Decks.Deck as Deck exposing (codeDecoder)
|
||||
import ManyDecks.Pages.Decks.Model exposing (..)
|
||||
import Material.Button as Button
|
||||
import Material.Card as Card
|
||||
|
||||
|
||||
view : Maybe (List CodeAndSummary) -> List (Html Msg)
|
||||
view decks =
|
||||
let
|
||||
renderedDecks =
|
||||
case decks of
|
||||
Just d ->
|
||||
d |> List.map deck |> HtmlK.ul []
|
||||
|
||||
Nothing ->
|
||||
Icon.spinner |> Icon.viewStyled [ Icon.spin ]
|
||||
|
||||
newDeck =
|
||||
Button.view Button.Raised
|
||||
Button.Padded
|
||||
"New Deck"
|
||||
(Icon.plus |> Icon.viewIcon |> Just)
|
||||
(Deck.empty |> NewDeck |> Just)
|
||||
|
||||
uploadDeck =
|
||||
Button.view Button.Raised
|
||||
Button.Padded
|
||||
"Upload Deck"
|
||||
(Icon.upload |> Icon.viewIcon |> Just)
|
||||
(Just UploadDeck)
|
||||
|
||||
controls =
|
||||
Html.div [ HtmlA.class "controls" ] [ uploadDeck, newDeck ]
|
||||
in
|
||||
[ Card.view [ HtmlA.class "decks" ] [ renderedDecks, controls ] ]
|
||||
|
||||
|
||||
deck : CodeAndSummary -> ( String, Html Msg )
|
||||
deck { code, summary } =
|
||||
( code |> Deck.codeToString
|
||||
, Html.li [ HtmlA.class "deck" ]
|
||||
[ Deck.viewCode Copy code
|
||||
, Html.div [ HtmlA.class "details", EditDeck code Nothing False |> HtmlE.onClick ]
|
||||
[ Html.span [ HtmlA.class "name", HtmlA.title summary.details.name ] [ Html.text summary.details.name ]
|
||||
, Html.span [ HtmlA.class "language" ] [ summary.details.language |> Maybe.withDefault "" |> Html.text ]
|
||||
]
|
||||
, Html.div [ HtmlA.class "cards" ]
|
||||
[ Html.span [ HtmlA.class "calls", HtmlA.title "Calls" ]
|
||||
[ summary.calls |> String.fromInt |> Html.text ]
|
||||
, Html.span [ HtmlA.class "responses", HtmlA.title "Responses" ]
|
||||
[ summary.responses |> String.fromInt |> Html.text ]
|
||||
]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
getDeck : Deck.Code -> (Result Http.Error Deck -> msg) -> Cmd msg
|
||||
getDeck code toMsg =
|
||||
Http.get
|
||||
{ url = "/api/decks/" ++ (code |> Deck.codeToString)
|
||||
, expect = Http.expectJson toMsg Deck.decode
|
||||
}
|
||||
|
||||
|
||||
getDecks : String -> (Result Http.Error (List CodeAndSummary) -> msg) -> Cmd msg
|
||||
getDecks token toMsg =
|
||||
Http.post
|
||||
{ url = "/api/decks"
|
||||
, body = [ ( "token", token |> Json.Encode.string ) ] |> Json.Encode.object |> Http.jsonBody
|
||||
, expect = Http.expectJson toMsg (summaryAndCodeDecoder |> Json.list)
|
||||
}
|
||||
|
||||
|
||||
createDeck : String -> Deck.Deck -> (Result Http.Error Deck.Code -> msg) -> Cmd msg
|
||||
createDeck token d toMsg =
|
||||
Http.post
|
||||
{ url = "/api/decks"
|
||||
, body =
|
||||
[ ( "token", token |> Json.Encode.string )
|
||||
, ( "initial", d |> Deck.encode )
|
||||
]
|
||||
|> Json.Encode.object
|
||||
|> Http.jsonBody
|
||||
, expect = Http.expectJson toMsg codeDecoder
|
||||
}
|
||||
|
||||
|
||||
deleteDeck : String -> Deck.Code -> Cmd Msg
|
||||
deleteDeck token code =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = []
|
||||
, url = "/api/decks/" ++ Deck.codeToString code
|
||||
, body = [ ( "token", token |> Json.Encode.string ) ] |> Json.Encode.object |> Http.jsonBody
|
||||
, expect = Http.expectWhatever (always NoOp)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
save : String -> Deck.Code -> Json.Patch -> (Result Http.Error () -> msg) -> Cmd msg
|
||||
save token code patch toMsg =
|
||||
Http.request
|
||||
{ method = "PATCH"
|
||||
, headers = []
|
||||
, url = "/api/decks/" ++ Deck.codeToString code
|
||||
, body =
|
||||
[ ( "token", token |> Json.Encode.string )
|
||||
, ( "patch", patch |> Json.toPatch |> Json.Patch.encoder )
|
||||
]
|
||||
|> Json.Encode.object
|
||||
|> Http.jsonBody
|
||||
, expect = Http.expectWhatever toMsg
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
|
||||
|
||||
backup : String -> (Result Http.Error Bytes -> msg) -> Cmd msg
|
||||
backup token toMsg =
|
||||
Http.post
|
||||
{ url = "/api/backup"
|
||||
, body =
|
||||
[ ( "token", token |> Json.Encode.string ) ]
|
||||
|> Json.Encode.object
|
||||
|> Http.jsonBody
|
||||
, expect = Http.expectBytesResponse toMsg resolve
|
||||
}
|
||||
|
||||
|
||||
resolve : Http.Response Bytes -> Result Http.Error Bytes
|
||||
resolve response =
|
||||
case response of
|
||||
Http.BadUrl_ url ->
|
||||
Err (Http.BadUrl url)
|
||||
|
||||
Http.Timeout_ ->
|
||||
Err Http.Timeout
|
||||
|
||||
Http.NetworkError_ ->
|
||||
Err Http.NetworkError
|
||||
|
||||
Http.BadStatus_ metadata _ ->
|
||||
Err (Http.BadStatus metadata.statusCode)
|
||||
|
||||
Http.GoodStatus_ _ body ->
|
||||
Ok body
|
||||
@@ -0,0 +1,190 @@
|
||||
module ManyDecks.Pages.Decks.Deck exposing
|
||||
( Call
|
||||
, Code
|
||||
, Details
|
||||
, EditableDeck
|
||||
, Line
|
||||
, Part(..)
|
||||
, Response
|
||||
, Style(..)
|
||||
, Summary
|
||||
, Transform(..)
|
||||
, codeDecoder
|
||||
, codeToString
|
||||
, detailsDecoder
|
||||
, editableDeckEncoder
|
||||
, summaryDecoder
|
||||
, viewCode
|
||||
)
|
||||
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Html.Events as HtmlE
|
||||
import Json.Decode as Json
|
||||
import Json.Decode.Pipeline as Json
|
||||
import Json.Encode
|
||||
|
||||
|
||||
type Code
|
||||
= Code String
|
||||
|
||||
|
||||
codeDecoder : Json.Decoder Code
|
||||
codeDecoder =
|
||||
Json.string |> Json.map Code
|
||||
|
||||
|
||||
viewCode : (String -> msg) -> Code -> Html msg
|
||||
viewCode copy (Code code) =
|
||||
Html.input
|
||||
[ code |> HtmlA.id
|
||||
, HtmlA.type_ "text"
|
||||
, HtmlA.readonly True
|
||||
, HtmlA.class "deck-code"
|
||||
, HtmlA.value code
|
||||
, code |> copy |> HtmlE.onClick
|
||||
]
|
||||
[]
|
||||
|
||||
|
||||
codeToString : Code -> String
|
||||
codeToString (Code code) =
|
||||
code
|
||||
|
||||
|
||||
type Transform
|
||||
= NoTransform
|
||||
| UpperCase
|
||||
| Capitalize
|
||||
|
||||
|
||||
type Style
|
||||
= NoStyle
|
||||
| Em
|
||||
|
||||
|
||||
type Part
|
||||
= Text String Style
|
||||
| Slot Transform Style
|
||||
|
||||
|
||||
type alias Line =
|
||||
List Part
|
||||
|
||||
|
||||
type alias Call =
|
||||
List Line
|
||||
|
||||
|
||||
type alias Response =
|
||||
String
|
||||
|
||||
|
||||
type alias Details =
|
||||
{ name : String
|
||||
, author : String
|
||||
, language : Maybe String
|
||||
}
|
||||
|
||||
|
||||
detailsDecoder : Json.Decoder Details
|
||||
detailsDecoder =
|
||||
Json.succeed Details
|
||||
|> Json.required "name" Json.string
|
||||
|> Json.required "author" Json.string
|
||||
|> Json.optional "language" (Json.string |> Json.map Just) Nothing
|
||||
|
||||
|
||||
type alias Summary =
|
||||
{ details : Details
|
||||
, calls : Int
|
||||
, responses : Int
|
||||
, version : Int
|
||||
}
|
||||
|
||||
|
||||
summaryDecoder : Json.Decoder Summary
|
||||
summaryDecoder =
|
||||
Json.succeed Summary
|
||||
|> Json.required "details" detailsDecoder
|
||||
|> Json.required "calls" Json.int
|
||||
|> Json.required "responses" Json.int
|
||||
|> Json.required "version" Json.int
|
||||
|
||||
|
||||
type alias Deck =
|
||||
{ details : Details
|
||||
, calls : List Call
|
||||
, responses : List Response
|
||||
, version : Int
|
||||
}
|
||||
|
||||
|
||||
type alias EditableDeck =
|
||||
{ name : String
|
||||
, language : String
|
||||
, calls : List Call
|
||||
, responses : List Response
|
||||
}
|
||||
|
||||
|
||||
editableDeckEncoder : EditableDeck -> Json.Value
|
||||
editableDeckEncoder { name, language, calls, responses } =
|
||||
Json.Encode.object
|
||||
[ ( "name", name |> Json.Encode.string )
|
||||
, ( "language", language |> Json.Encode.string )
|
||||
, ( "calls", calls |> Json.Encode.list encodeCall )
|
||||
, ( "responses", responses |> Json.Encode.list Json.Encode.string )
|
||||
]
|
||||
|
||||
|
||||
encodeCall : List (List Part) -> Json.Encode.Value
|
||||
encodeCall =
|
||||
Json.Encode.list encodeLine
|
||||
|
||||
|
||||
encodeLine : List Part -> Json.Encode.Value
|
||||
encodeLine =
|
||||
Json.Encode.list encodePart
|
||||
|
||||
|
||||
encodePart : Part -> Json.Encode.Value
|
||||
encodePart part =
|
||||
let
|
||||
fields =
|
||||
case part of
|
||||
Text text style ->
|
||||
[ text |> encodeText, style |> encodeStyle ]
|
||||
|
||||
Slot transform style ->
|
||||
[ transform |> encodeTransform, style |> encodeStyle ]
|
||||
in
|
||||
fields |> List.concat |> Json.Encode.object
|
||||
|
||||
|
||||
encodeText : String -> List ( String, Json.Encode.Value )
|
||||
encodeText text =
|
||||
[ ( "text", text |> Json.Encode.string ) ]
|
||||
|
||||
|
||||
encodeStyle : Style -> List ( String, Json.Encode.Value )
|
||||
encodeStyle style =
|
||||
case style of
|
||||
NoStyle ->
|
||||
[]
|
||||
|
||||
Em ->
|
||||
[ ( "style", "Em" |> Json.Encode.string ) ]
|
||||
|
||||
|
||||
encodeTransform : Transform -> List ( String, Json.Encode.Value )
|
||||
encodeTransform transform =
|
||||
case transform of
|
||||
NoTransform ->
|
||||
[]
|
||||
|
||||
Capitalize ->
|
||||
[ ( "transform", "Capitalize" |> Json.Encode.string ) ]
|
||||
|
||||
UpperCase ->
|
||||
[ ( "transform", "UpperCase" |> Json.Encode.string ) ]
|
||||
@@ -0,0 +1,18 @@
|
||||
module ManyDecks.Pages.Decks.Model exposing (..)
|
||||
|
||||
import Json.Decode as Json
|
||||
import Json.Decode.Pipeline as Json
|
||||
import ManyDecks.Pages.Decks.Deck as Deck
|
||||
|
||||
|
||||
type alias CodeAndSummary =
|
||||
{ code : Deck.Code
|
||||
, summary : Deck.Summary
|
||||
}
|
||||
|
||||
|
||||
summaryAndCodeDecoder : Json.Decoder CodeAndSummary
|
||||
summaryAndCodeDecoder =
|
||||
Json.succeed CodeAndSummary
|
||||
|> Json.required "code" Deck.codeDecoder
|
||||
|> Json.required "summary" Deck.summaryDecoder
|
||||
@@ -0,0 +1,488 @@
|
||||
module ManyDecks.Pages.Edit exposing
|
||||
( init
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
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.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 Json.Patch.Invertible as Json
|
||||
import List.Extra as List
|
||||
import ManyDecks.Pages.Decks.Deck as Deck
|
||||
import ManyDecks.Pages.Edit.CallEditor as CallEditor
|
||||
import ManyDecks.Pages.Edit.CallEditor.Model as CallEditor
|
||||
import ManyDecks.Pages.Edit.Change as Change
|
||||
import ManyDecks.Pages.Edit.Import as Import
|
||||
import ManyDecks.Pages.Edit.Import.Model as Import
|
||||
import ManyDecks.Pages.Edit.Model exposing (..)
|
||||
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
|
||||
|
||||
|
||||
init : Deck.Code -> Deck -> Model
|
||||
init code deck =
|
||||
{ code = code
|
||||
, deck = deck
|
||||
, editing = Nothing
|
||||
, changes = []
|
||||
, redoStack = []
|
||||
, errors = []
|
||||
, deletionEnabled = False
|
||||
, importer = Nothing
|
||||
}
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
Edit u ->
|
||||
case u of
|
||||
UpdateName newName ->
|
||||
let
|
||||
editing =
|
||||
case model.editing of
|
||||
Just (NameEditor old _) ->
|
||||
NameEditor old newName |> Just
|
||||
|
||||
other ->
|
||||
other
|
||||
in
|
||||
( { model | editing = editing }, Cmd.none )
|
||||
|
||||
UpdateResponse newResponse ->
|
||||
let
|
||||
editing =
|
||||
case model.editing of
|
||||
Just (ResponseEditor index old _) ->
|
||||
ResponseEditor index old newResponse |> Just
|
||||
|
||||
other ->
|
||||
other
|
||||
in
|
||||
( { model | editing = editing }, Cmd.none )
|
||||
|
||||
UpdateCall callEditorMsg ->
|
||||
let
|
||||
editing =
|
||||
case model.editing of
|
||||
Just (CallEditor index old m) ->
|
||||
m |> CallEditor.update callEditorMsg |> CallEditor index old |> Just
|
||||
|
||||
other ->
|
||||
other
|
||||
in
|
||||
( { model | editing = editing }, Cmd.none )
|
||||
|
||||
StartEditing cardEditor ->
|
||||
let
|
||||
m =
|
||||
endEditing model
|
||||
in
|
||||
( { m | editing = Just cardEditor }, Cmd.none )
|
||||
|
||||
EndEditing ->
|
||||
( endEditing model, Cmd.none )
|
||||
|
||||
Delete ->
|
||||
case model.editing of
|
||||
Just (CallEditor index old _) ->
|
||||
( model |> applyChanges [ Remove index old |> CallChange ], Cmd.none )
|
||||
|
||||
Just (ResponseEditor index old _) ->
|
||||
( model |> applyChanges [ Remove index old |> ResponseChange ], Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
ApplyChange change ->
|
||||
( model |> applyChanges [ change ], Cmd.none )
|
||||
|
||||
Undo ->
|
||||
case model.changes |> List.unconsLast of
|
||||
Just ( last, rest ) ->
|
||||
case model.deck |> Change.undo [ last ] of
|
||||
Ok deck ->
|
||||
( { model | changes = rest, deck = deck, redoStack = last :: model.redoStack }, Cmd.none )
|
||||
|
||||
Err error ->
|
||||
( addChangeError [ last ] error True model, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
Redo ->
|
||||
case model.redoStack of
|
||||
first :: rest ->
|
||||
case model.deck |> Change.apply [ first ] of
|
||||
Ok deck ->
|
||||
( { model | changes = model.changes ++ [ first ], deck = deck, redoStack = rest }, Cmd.none )
|
||||
|
||||
Err error ->
|
||||
( addChangeError [ first ] error False model, Cmd.none )
|
||||
|
||||
[] ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SetDeletionEnabled enabled ->
|
||||
( { model | deletionEnabled = enabled }, Cmd.none )
|
||||
|
||||
SetImportVisible visible ->
|
||||
let
|
||||
importer =
|
||||
if visible then
|
||||
Just Import.init
|
||||
|
||||
else
|
||||
Nothing
|
||||
in
|
||||
( { model | importer = importer }, Cmd.none )
|
||||
|
||||
Import ->
|
||||
case model.importer of
|
||||
Just importer ->
|
||||
let
|
||||
cards =
|
||||
importer |> Import.importedCards
|
||||
|
||||
toChangeAndApply card m =
|
||||
let
|
||||
change =
|
||||
case card of
|
||||
Import.ImportedCall c ->
|
||||
Add (m.deck.calls |> List.length) c |> CallChange
|
||||
|
||||
Import.ImportedResponse r ->
|
||||
Add (m.deck.responses |> List.length) r |> ResponseChange
|
||||
in
|
||||
applyChanges [ change ] m
|
||||
|
||||
newModel =
|
||||
cards |> List.foldl toChangeAndApply model
|
||||
in
|
||||
( { newModel | importer = Nothing }, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
UpdateImportText text ->
|
||||
case model.importer of
|
||||
Just importer ->
|
||||
( { model | importer = Just { importer | text = text } }, Cmd.none )
|
||||
|
||||
Nothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
view : msg -> (Deck.Code -> Json.Patch -> msg) -> (Deck.Code -> msg) -> (Msg -> msg) -> Model -> List (Html msg)
|
||||
view back save delete wrap model =
|
||||
case model.importer of
|
||||
Just importer ->
|
||||
Import.view wrap importer
|
||||
|
||||
Nothing ->
|
||||
let
|
||||
viewResponse r =
|
||||
[ Response.view (UpdateResponse >> Edit >> wrap |> GameCard.Mutable) GameCard.Face r ]
|
||||
|
||||
editing editor =
|
||||
case editor of
|
||||
CallEditor _ _ editorModel ->
|
||||
inEditing [ CallEditor.view (UpdateCall >> Edit >> wrap) editorModel ] (Just editorModel) |> Just
|
||||
|
||||
ResponseEditor _ _ new ->
|
||||
inEditing (viewResponse new) Nothing |> Just
|
||||
|
||||
NameEditor _ _ ->
|
||||
Nothing
|
||||
|
||||
inEditing c callEditor =
|
||||
let
|
||||
d =
|
||||
IconButton.view (Icon.trash |> Icon.viewIcon) "Delete" (Delete |> wrap |> Just)
|
||||
|
||||
( problems, addSlot ) =
|
||||
case callEditor of
|
||||
Just editorModel ->
|
||||
( CallEditor.problems editorModel
|
||||
, IconButton.view (Icon.plusCircle |> Icon.viewIcon)
|
||||
"Add Slot"
|
||||
(CallEditor.AddSlot |> UpdateCall |> Edit |> wrap |> Just)
|
||||
|> Just
|
||||
)
|
||||
|
||||
_ ->
|
||||
( [], Nothing )
|
||||
|
||||
noProblems =
|
||||
List.isEmpty problems
|
||||
|
||||
sAction =
|
||||
if noProblems then
|
||||
EndEditing |> wrap |> Just
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
s =
|
||||
IconButton.view (Icon.save |> Icon.viewIcon) "Save" sAction
|
||||
|
||||
controlsContents =
|
||||
[ Just d, addSlot, Just s ] |> List.filterMap identity
|
||||
|
||||
problemsView =
|
||||
if noProblems then
|
||||
Html.text ""
|
||||
|
||||
else
|
||||
Html.ul [ HtmlA.class "problems" ]
|
||||
(problems |> List.map (\p -> Html.li [] [ Html.text p ]))
|
||||
in
|
||||
[ Html.div [ HtmlA.class "overlay" ]
|
||||
[ Html.div [ HtmlA.class "background", EndEditing |> wrap |> HtmlE.onClick ] []
|
||||
, Card.view []
|
||||
[ Html.div [ HtmlA.class "editing" ] c
|
||||
, problemsView
|
||||
, Html.div [ HtmlA.class "editing-controls" ] controlsContents
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
undoAction =
|
||||
if List.isEmpty model.changes then
|
||||
Nothing
|
||||
|
||||
else
|
||||
Undo |> wrap |> Just
|
||||
|
||||
redoAction =
|
||||
if List.isEmpty model.redoStack then
|
||||
Nothing
|
||||
|
||||
else
|
||||
Redo |> wrap |> Just
|
||||
|
||||
saveAction =
|
||||
if List.isEmpty model.changes then
|
||||
Nothing
|
||||
|
||||
else
|
||||
save model.code (model.changes |> Change.toPatch) |> Just
|
||||
|
||||
actions =
|
||||
Html.div [ HtmlA.class "actions" ]
|
||||
[ Button.view
|
||||
Button.Standard
|
||||
Button.Padded
|
||||
"Back"
|
||||
(Icon.arrowLeft |> Icon.viewIcon |> Just)
|
||||
(back |> Just)
|
||||
, Button.view
|
||||
Button.Standard
|
||||
Button.Padded
|
||||
"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
|
||||
]
|
||||
|
||||
errorView =
|
||||
if model.errors |> List.isEmpty then
|
||||
[]
|
||||
|
||||
else
|
||||
[ Html.div [ HtmlA.class "overlay" ]
|
||||
[ Html.div [ HtmlA.class "background" ] []
|
||||
, Card.view [ HtmlA.class "errors" ]
|
||||
[ Html.ul [] (model.errors |> List.map (viewError model.deck)) ]
|
||||
]
|
||||
]
|
||||
|
||||
deleteAction =
|
||||
if model.deletionEnabled then
|
||||
model.code |> delete |> Just
|
||||
|
||||
else
|
||||
Nothing
|
||||
in
|
||||
List.concat
|
||||
[ [ Card.view [ HtmlA.class "edit" ]
|
||||
[ actions
|
||||
, details wrap (model |> editingName |> Maybe.withDefault model.deck.name)
|
||||
, Html.div [ HtmlA.class "cards" ]
|
||||
[ calls wrap model.deck.calls
|
||||
, responses wrap model.deck.responses
|
||||
]
|
||||
, Html.div [ HtmlA.class "delete" ]
|
||||
[ Switch.view
|
||||
(Html.span []
|
||||
[ Html.text "I am sure I want to "
|
||||
, Html.strong [] [ Html.text "permanently" ]
|
||||
, Html.text " delete this deck."
|
||||
]
|
||||
)
|
||||
model.deletionEnabled
|
||||
(SetDeletionEnabled >> wrap |> Just)
|
||||
, Button.view
|
||||
Button.Raised
|
||||
Button.Padded
|
||||
"Delete"
|
||||
(Icon.trash |> Icon.viewIcon |> Just)
|
||||
deleteAction
|
||||
]
|
||||
]
|
||||
]
|
||||
, model.editing |> Maybe.andThen editing |> Maybe.withDefault []
|
||||
, errorView
|
||||
]
|
||||
|
||||
|
||||
subscriptions : (Msg -> msg) -> Model -> Sub msg
|
||||
subscriptions wrap model =
|
||||
case model.editing of
|
||||
Just (CallEditor _ _ _) ->
|
||||
CallEditor.subscriptions (UpdateCall >> Edit >> wrap)
|
||||
|
||||
_ ->
|
||||
Sub.none
|
||||
|
||||
|
||||
viewError : Deck -> EditError -> Html msg
|
||||
viewError deck error =
|
||||
let
|
||||
content =
|
||||
case error of
|
||||
ChangeError e changes undoing ->
|
||||
[ Change.asContextForError e changes undoing ]
|
||||
in
|
||||
Html.li [ HtmlA.class "error" ] content
|
||||
|
||||
|
||||
editingName : Model -> Maybe String
|
||||
editingName model =
|
||||
case model.editing of
|
||||
Just (NameEditor _ new) ->
|
||||
Just new
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
details : (Msg -> msg) -> String -> Html msg
|
||||
details wrap name =
|
||||
Html.div [ HtmlA.class "details" ]
|
||||
[ TextField.viewWithFocus "Title"
|
||||
TextField.Text
|
||||
name
|
||||
(UpdateName >> Edit >> wrap |> Just)
|
||||
(NameEditor name name |> StartEditing |> wrap)
|
||||
(EndEditing |> wrap)
|
||||
]
|
||||
|
||||
|
||||
calls : (Msg -> msg) -> List Call -> Html msg
|
||||
calls wrap cs =
|
||||
let
|
||||
add =
|
||||
Button.view
|
||||
Button.Standard
|
||||
Button.Padded
|
||||
"New Call"
|
||||
(Icon.square |> Icon.viewIcon |> Just)
|
||||
(Add (cs |> List.length) Call.init |> CallChange |> ApplyChange |> wrap |> Just)
|
||||
|
||||
content =
|
||||
(cs |> List.indexedMap (call wrap)) ++ [ Html.li [ HtmlA.class "add" ] [ add ] ]
|
||||
in
|
||||
Html.ul [ HtmlA.class "calls" ] content
|
||||
|
||||
|
||||
call : (Msg -> msg) -> Int -> Call -> Html msg
|
||||
call wrap index c =
|
||||
Html.li [ CallEditor index c (Call.editor c) |> StartEditing |> wrap |> HtmlE.onClick ]
|
||||
[ c |> Call.toString "⏎" [] |> Html.text ]
|
||||
|
||||
|
||||
responses : (Msg -> msg) -> List Response -> Html msg
|
||||
responses wrap rs =
|
||||
let
|
||||
add =
|
||||
Button.view
|
||||
Button.Standard
|
||||
Button.Padded
|
||||
"New Response"
|
||||
(RegularIcon.square |> Icon.viewIcon |> Just)
|
||||
(Add (rs |> List.length) Response.init |> ResponseChange |> ApplyChange |> wrap |> Just)
|
||||
|
||||
content =
|
||||
(rs |> List.indexedMap (response wrap)) ++ [ Html.li [ HtmlA.class "add" ] [ add ] ]
|
||||
in
|
||||
Html.ul [ HtmlA.class "responses" ] content
|
||||
|
||||
|
||||
response : (Msg -> msg) -> Int -> Response -> Html msg
|
||||
response wrap index r =
|
||||
Html.li [ ResponseEditor index r r |> StartEditing |> wrap |> HtmlE.onClick ]
|
||||
[ r |> Response.toString |> Html.text ]
|
||||
|
||||
|
||||
endEditing : Model -> Model
|
||||
endEditing model =
|
||||
case model.editing of
|
||||
Just finished ->
|
||||
let
|
||||
changes =
|
||||
finished |> Change.fromEditor
|
||||
in
|
||||
case changes of
|
||||
Ok cs ->
|
||||
applyChanges cs model
|
||||
|
||||
Err _ ->
|
||||
model
|
||||
|
||||
Nothing ->
|
||||
model
|
||||
|
||||
|
||||
applyChanges : List Change -> Model -> Model
|
||||
applyChanges changes model =
|
||||
case model.deck |> Change.apply changes of
|
||||
Ok deck ->
|
||||
{ model | changes = model.changes ++ changes, deck = deck, editing = Nothing, redoStack = [] }
|
||||
|
||||
Err error ->
|
||||
addChangeError changes error False model
|
||||
|
||||
|
||||
addChangeError : List Change -> String -> Bool -> Model -> Model
|
||||
addChangeError changes error undo model =
|
||||
{ model | errors = ChangeError error changes undo :: model.errors }
|
||||
@@ -0,0 +1,409 @@
|
||||
module ManyDecks.Pages.Edit.CallEditor exposing
|
||||
( problems
|
||||
, subscriptions
|
||||
, update
|
||||
, view
|
||||
)
|
||||
|
||||
import Browser.Events as Browser
|
||||
import Cards.Call as Call
|
||||
import Cards.Call.Style as Style
|
||||
import Cards.Call.Transform as Transform
|
||||
import Cards.Card as Card
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Html.Events as HtmlE
|
||||
import Json.Decode as Json
|
||||
import List.Extra as List
|
||||
import ManyDecks.Pages.Edit.CallEditor.Model exposing (..)
|
||||
|
||||
|
||||
deleteSpan : Span -> Model -> Model
|
||||
deleteSpan { start, end } model =
|
||||
let
|
||||
left =
|
||||
model.atoms |> List.take start
|
||||
|
||||
right =
|
||||
model.atoms |> List.drop end
|
||||
in
|
||||
{ model | atoms = List.concat [ left, right ], selection = Nothing, cursor = start }
|
||||
|
||||
|
||||
insertAt : List Atom -> Position -> Model -> Model
|
||||
insertAt new position model =
|
||||
let
|
||||
( left, right ) =
|
||||
model.atoms |> List.splitAt position
|
||||
|
||||
atoms =
|
||||
List.concat [ left, new, right ]
|
||||
in
|
||||
{ model | atoms = atoms, cursor = position + List.length new }
|
||||
|
||||
|
||||
moveCursor : (Position -> Position) -> Model -> Model
|
||||
moveCursor move model =
|
||||
let
|
||||
afterLast =
|
||||
List.length model.atoms
|
||||
|
||||
cursor =
|
||||
min afterLast (max (model.cursor |> move) 0)
|
||||
|
||||
selection =
|
||||
case model.selecting of
|
||||
Just start ->
|
||||
selectionOf start cursor
|
||||
|
||||
Nothing ->
|
||||
Nothing
|
||||
in
|
||||
{ model | cursor = cursor, selection = selection }
|
||||
|
||||
|
||||
moveRow : Int -> Model -> Model
|
||||
moveRow diff model =
|
||||
let
|
||||
applyNTimes n f value =
|
||||
if n > 0 then
|
||||
value |> f |> applyNTimes (n - 1) f
|
||||
|
||||
else
|
||||
value
|
||||
|
||||
atoms =
|
||||
model.atoms
|
||||
in
|
||||
if diff > 0 then
|
||||
model |> applyNTimes diff (moveCursor (\p -> p + toEndOfLine atoms p + 1))
|
||||
|
||||
else
|
||||
model |> applyNTimes -diff (moveCursor (\p -> p - toStartOfLine atoms p - 1))
|
||||
|
||||
|
||||
toEndOfLine : List Atom -> Position -> Int
|
||||
toEndOfLine atoms p =
|
||||
atoms
|
||||
|> List.drop p
|
||||
|> List.findIndex ((==) NewLine)
|
||||
|> Maybe.withDefault (List.length atoms)
|
||||
|
||||
|
||||
toStartOfLine : List Atom -> Position -> Int
|
||||
toStartOfLine atoms p =
|
||||
let
|
||||
pRev =
|
||||
List.length atoms - p
|
||||
in
|
||||
atoms
|
||||
|> List.reverse
|
||||
|> List.drop pRev
|
||||
|> List.findIndex ((==) NewLine)
|
||||
|> Maybe.withDefault p
|
||||
|
||||
|
||||
update : Msg -> Model -> Model
|
||||
update msg model =
|
||||
case msg of
|
||||
KeyUp key ->
|
||||
case key of
|
||||
Control "Shift" ->
|
||||
{ model | selecting = Nothing }
|
||||
|
||||
Control "Control" ->
|
||||
{ model | control = False }
|
||||
|
||||
_ ->
|
||||
model
|
||||
|
||||
KeyDown key ->
|
||||
let
|
||||
cursor =
|
||||
model.cursor
|
||||
|
||||
op =
|
||||
case key of
|
||||
Control "Delete" ->
|
||||
case model.selection of
|
||||
Nothing ->
|
||||
deleteSpan (span cursor (cursor + 1))
|
||||
|
||||
Just selection ->
|
||||
deleteSpan selection
|
||||
|
||||
Control "Backspace" ->
|
||||
case model.selection of
|
||||
Nothing ->
|
||||
deleteSpan (span (cursor - 1) cursor)
|
||||
|
||||
Just selection ->
|
||||
deleteSpan selection
|
||||
|
||||
Control "Enter" ->
|
||||
case model.selection of
|
||||
Nothing ->
|
||||
insertAt [ NewLine ] cursor
|
||||
|
||||
Just selection ->
|
||||
deleteSpan selection >> (\m -> insertAt [ NewLine ] m.cursor m)
|
||||
|
||||
Control "ArrowLeft" ->
|
||||
moveCursor (\c -> c - 1)
|
||||
|
||||
Control "ArrowRight" ->
|
||||
moveCursor (\c -> c + 1)
|
||||
|
||||
Control "ArrowUp" ->
|
||||
moveRow -1
|
||||
|
||||
Control "ArrowDown" ->
|
||||
moveRow 1
|
||||
|
||||
Control "End" ->
|
||||
moveCursor (\p -> p + toEndOfLine model.atoms p)
|
||||
|
||||
Control "Home" ->
|
||||
moveCursor (\p -> p - toStartOfLine model.atoms p)
|
||||
|
||||
Control "Shift" ->
|
||||
\m -> { m | selecting = Just m.cursor }
|
||||
|
||||
Control "Control" ->
|
||||
\m -> { m | control = True }
|
||||
|
||||
Character char ->
|
||||
if model.control then
|
||||
identity
|
||||
|
||||
else
|
||||
case model.selection of
|
||||
Nothing ->
|
||||
insertAt [ Letter char ] cursor
|
||||
|
||||
Just selection ->
|
||||
deleteSpan selection >> insertAt [ Letter char ] selection.start
|
||||
|
||||
_ ->
|
||||
identity
|
||||
in
|
||||
op model
|
||||
|
||||
Enter position ->
|
||||
let
|
||||
( selection, cursor ) =
|
||||
case model.selecting of
|
||||
Just start ->
|
||||
let
|
||||
end =
|
||||
if position <= start then
|
||||
position
|
||||
|
||||
else
|
||||
position + 1
|
||||
in
|
||||
( selectionOf start end, end )
|
||||
|
||||
Nothing ->
|
||||
let
|
||||
c =
|
||||
if model.moving /= Nothing then
|
||||
position
|
||||
|
||||
else
|
||||
model.cursor
|
||||
in
|
||||
( model.selection, c )
|
||||
in
|
||||
{ model | hover = Just position, selection = selection, cursor = cursor }
|
||||
|
||||
Leave position ->
|
||||
let
|
||||
hover =
|
||||
if model.hover == Just position then
|
||||
Nothing
|
||||
|
||||
else
|
||||
model.hover
|
||||
in
|
||||
{ model | hover = hover }
|
||||
|
||||
StartSelection position ->
|
||||
{ model
|
||||
| selection = Nothing
|
||||
, selecting = Just position
|
||||
, cursor = position
|
||||
}
|
||||
|
||||
StartMoving position ->
|
||||
{ model | moving = Just position }
|
||||
|
||||
EndSelection position ->
|
||||
let
|
||||
( s, cursor ) =
|
||||
case model.selecting of
|
||||
Just start ->
|
||||
let
|
||||
end =
|
||||
if position <= start then
|
||||
position
|
||||
|
||||
else
|
||||
position + 1
|
||||
in
|
||||
( selectionOf start end, end )
|
||||
|
||||
Nothing ->
|
||||
( Nothing, position )
|
||||
|
||||
m =
|
||||
{ model | selection = s, selecting = Nothing, cursor = cursor }
|
||||
|
||||
newModel =
|
||||
case m.moving of
|
||||
Just from ->
|
||||
if from == position then
|
||||
{ m | selection = span from (from + 1) |> Just }
|
||||
|
||||
else
|
||||
let
|
||||
value =
|
||||
m.atoms
|
||||
|> List.getAt from
|
||||
|> Maybe.map (\v -> [ v ])
|
||||
|> Maybe.withDefault []
|
||||
|
||||
original =
|
||||
if position < from then
|
||||
from + 1
|
||||
|
||||
else
|
||||
from
|
||||
in
|
||||
m
|
||||
|> insertAt value position
|
||||
|> deleteSpan (span original (original + 1))
|
||||
|
||||
Nothing ->
|
||||
m
|
||||
in
|
||||
{ newModel | moving = Nothing }
|
||||
|
||||
AddSlot ->
|
||||
model |> insertAt [ Slot Transform.None Style.None ] model.cursor
|
||||
|
||||
|
||||
subscriptions : (Msg -> msg) -> Sub msg
|
||||
subscriptions wrap =
|
||||
Sub.batch
|
||||
[ Browser.onKeyDown (keyDecoder |> Json.map (KeyDown >> wrap))
|
||||
, Browser.onKeyUp (keyDecoder |> Json.map (KeyUp >> wrap))
|
||||
]
|
||||
|
||||
|
||||
lines : List Atom -> List (List ( Int, Atom ))
|
||||
lines =
|
||||
List.indexedMap (\i a -> ( i, a ))
|
||||
>> List.groupWhile (\( _, a ) _ -> a /= NewLine)
|
||||
>> List.map (\( f, r ) -> f :: r)
|
||||
|
||||
|
||||
view : (Msg -> msg) -> Model -> Html msg
|
||||
view wrap model =
|
||||
let
|
||||
content =
|
||||
model.atoms ++ [ NewLine ] |> lines |> List.map (viewLine wrap model)
|
||||
in
|
||||
Html.div [] [ Card.view Call.type_ Card.Immutable content Card.Face ]
|
||||
|
||||
|
||||
problems : Model -> List String
|
||||
problems model =
|
||||
if model.atoms |> List.any isSlot then
|
||||
[]
|
||||
|
||||
else
|
||||
[ "Calls must contain at least one slot." ]
|
||||
|
||||
|
||||
selectionOf : Position -> Position -> Maybe Span
|
||||
selectionOf start end =
|
||||
if start /= end then
|
||||
span start end |> Just
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
|
||||
viewLine : (Msg -> msg) -> Model -> List ( Int, Atom ) -> Html msg
|
||||
viewLine wrap model line =
|
||||
Html.p [] (line |> List.map (viewAtom wrap model))
|
||||
|
||||
|
||||
viewAtom : (Msg -> msg) -> Model -> ( Int, Atom ) -> Html msg
|
||||
viewAtom wrap model ( position, atom ) =
|
||||
let
|
||||
attrs =
|
||||
[ position |> Enter |> wrap |> HtmlE.onMouseEnter
|
||||
, position |> Leave |> wrap |> HtmlE.onMouseLeave
|
||||
, position |> EndSelection |> wrap |> HtmlE.onMouseUp
|
||||
, HtmlA.classList
|
||||
[ ( "cursor", model.cursor == position )
|
||||
, ( "selected", model.selection |> Maybe.map (inSpan position) |> Maybe.withDefault False )
|
||||
]
|
||||
]
|
||||
in
|
||||
case atom of
|
||||
Letter char ->
|
||||
Html.span
|
||||
([ position |> StartSelection |> wrap |> HtmlE.onMouseDown ] ++ attrs)
|
||||
[ char |> String.fromChar |> Html.text ]
|
||||
|
||||
Slot _ _ ->
|
||||
let
|
||||
slotAttrs =
|
||||
[ HtmlA.class "slot empty", position |> StartMoving |> wrap |> HtmlE.onMouseDown ]
|
||||
in
|
||||
Html.span (slotAttrs ++ attrs) []
|
||||
|
||||
NewLine ->
|
||||
Html.span ([ HtmlA.class "spacer" ] ++ attrs) []
|
||||
|
||||
|
||||
span : Position -> Position -> Span
|
||||
span a b =
|
||||
if a < b then
|
||||
Span a b
|
||||
|
||||
else
|
||||
Span b a
|
||||
|
||||
|
||||
inSpan : Position -> Span -> Bool
|
||||
inSpan position { start, end } =
|
||||
position >= start && position < end
|
||||
|
||||
|
||||
keyDecoder : Json.Decoder Key
|
||||
keyDecoder =
|
||||
Json.map toKey (Json.field "key" Json.string)
|
||||
|
||||
|
||||
toKey : String -> Key
|
||||
toKey string =
|
||||
case String.uncons string of
|
||||
Just ( char, "" ) ->
|
||||
Character char
|
||||
|
||||
_ ->
|
||||
Control string
|
||||
|
||||
|
||||
isSlot : Atom -> Bool
|
||||
isSlot atom =
|
||||
case atom of
|
||||
Slot _ _ ->
|
||||
True
|
||||
|
||||
_ ->
|
||||
False
|
||||
@@ -0,0 +1,48 @@
|
||||
module ManyDecks.Pages.Edit.CallEditor.Model exposing (..)
|
||||
|
||||
import Cards.Call.Style exposing (Style)
|
||||
import Cards.Call.Transform exposing (Transform)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ atoms : List Atom
|
||||
, selection : Maybe Span
|
||||
, selecting : Maybe Position
|
||||
, moving : Maybe Position
|
||||
, hover : Maybe Position
|
||||
, cursor : Position
|
||||
, styled : List ( Span, Style )
|
||||
, control : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias Position =
|
||||
Int
|
||||
|
||||
|
||||
type alias Span =
|
||||
{ start : Position
|
||||
, end : Position
|
||||
}
|
||||
|
||||
|
||||
type Atom
|
||||
= Letter Char
|
||||
| Slot Transform Style
|
||||
| NewLine
|
||||
|
||||
|
||||
type Msg
|
||||
= Enter Position
|
||||
| Leave Position
|
||||
| StartSelection Position
|
||||
| EndSelection Position
|
||||
| StartMoving Position
|
||||
| KeyDown Key
|
||||
| KeyUp Key
|
||||
| AddSlot
|
||||
|
||||
|
||||
type Key
|
||||
= Character Char
|
||||
| Control String
|
||||
@@ -0,0 +1,109 @@
|
||||
module ManyDecks.Pages.Edit.Change exposing
|
||||
( apply
|
||||
, asContextForError
|
||||
, fromEditor
|
||||
, toPatch
|
||||
, undo
|
||||
)
|
||||
|
||||
import Cards.Call as Call
|
||||
import Cards.Deck as Deck exposing (Deck)
|
||||
import Cards.Response as Response
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Json.Decode
|
||||
import Json.Encode as Json
|
||||
import Json.Patch
|
||||
import Json.Patch.Invertible as Json
|
||||
import Json.Pointer as Json
|
||||
import ManyDecks.Pages.Edit.Model exposing (..)
|
||||
|
||||
|
||||
apply : List Change -> Deck -> Result String Deck
|
||||
apply changes deck =
|
||||
applyPatchToDeck (changes |> toPatch) deck
|
||||
|
||||
|
||||
undo : List Change -> Deck -> Result String Deck
|
||||
undo changes deck =
|
||||
applyPatchToDeck (changes |> toPatch |> Json.invert) deck
|
||||
|
||||
|
||||
toPatch : List Change -> Json.Patch
|
||||
toPatch changes =
|
||||
changes |> List.map toOperation
|
||||
|
||||
|
||||
asContextForError : String -> List Change -> Bool -> Html msg
|
||||
asContextForError error changes undoing =
|
||||
let
|
||||
u =
|
||||
if undoing then
|
||||
Json.invert
|
||||
|
||||
else
|
||||
identity
|
||||
in
|
||||
Html.div [ HtmlA.class "error-with-context" ]
|
||||
[ Html.span [ HtmlA.class "message" ] [ Html.text error ]
|
||||
, Html.span [ HtmlA.class "change" ]
|
||||
[ changes |> toPatch |> u |> Json.toPatch |> Json.Patch.encoder |> Json.encode 2 |> Html.text ]
|
||||
]
|
||||
|
||||
|
||||
fromEditor : CardEditor -> Result String (List Change)
|
||||
fromEditor editor =
|
||||
let
|
||||
ifChanged wrap index old new =
|
||||
if old /= new then
|
||||
[ Replace index old new |> wrap ]
|
||||
|
||||
else
|
||||
[]
|
||||
in
|
||||
case editor of
|
||||
CallEditor index old new ->
|
||||
Call.editorToCall new |> Result.map (ifChanged CallChange index old)
|
||||
|
||||
ResponseEditor index old new ->
|
||||
ifChanged ResponseChange index old new |> Ok
|
||||
|
||||
NameEditor old new ->
|
||||
if old /= new then
|
||||
[ ChangeName old new ] |> Ok
|
||||
|
||||
else
|
||||
[] |> Ok
|
||||
|
||||
|
||||
applyPatchToDeck : Json.Patch -> Deck -> Result String Deck
|
||||
applyPatchToDeck patch =
|
||||
Deck.encode
|
||||
>> Json.Patch.apply (Json.toPatch patch)
|
||||
>> Result.andThen (Json.Decode.decodeValue Deck.decode >> Result.mapError Json.Decode.errorToString)
|
||||
|
||||
|
||||
toOperation : Change -> Json.Operation
|
||||
toOperation change =
|
||||
case change of
|
||||
ChangeName old newName ->
|
||||
Json.Replace [ "name" ] (old |> Json.string) (newName |> Json.string)
|
||||
|
||||
CallChange cardChange ->
|
||||
handleCardChange [ "calls" ] Call.encode cardChange
|
||||
|
||||
ResponseChange cardChange ->
|
||||
handleCardChange [ "responses" ] Response.encode cardChange
|
||||
|
||||
|
||||
handleCardChange : Json.Pointer -> (value -> Json.Value) -> CardChange value -> Json.Operation
|
||||
handleCardChange basePath encodeValue cardChange =
|
||||
case cardChange of
|
||||
Add index value ->
|
||||
Json.Add (basePath ++ [ index |> String.fromInt ]) (encodeValue value)
|
||||
|
||||
Replace index old value ->
|
||||
Json.Replace (basePath ++ [ index |> String.fromInt ]) (encodeValue old) (encodeValue value)
|
||||
|
||||
Remove index old ->
|
||||
Json.Remove (basePath ++ [ index |> String.fromInt ]) (encodeValue old)
|
||||
@@ -0,0 +1,68 @@
|
||||
module ManyDecks.Pages.Edit.Import exposing
|
||||
( importedCards
|
||||
, init
|
||||
, view
|
||||
)
|
||||
|
||||
import Cards.Call as Call exposing (Call)
|
||||
import Cards.Response as Response exposing (Response)
|
||||
import FontAwesome.Icon as Icon
|
||||
import FontAwesome.Solid as Icon
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Html.Events as HtmlE
|
||||
import ManyDecks.Pages.Edit.Import.Model exposing (..)
|
||||
import ManyDecks.Pages.Edit.Model exposing (Msg(..))
|
||||
import Material.Button as Button
|
||||
import Material.Card as Card
|
||||
|
||||
|
||||
init : Model
|
||||
init =
|
||||
{ text = "" }
|
||||
|
||||
|
||||
view : (Msg -> msg) -> Model -> List (Html msg)
|
||||
view wrap model =
|
||||
let
|
||||
importAction =
|
||||
if String.isEmpty model.text then
|
||||
Nothing
|
||||
|
||||
else
|
||||
Import |> wrap |> Just
|
||||
in
|
||||
[ Card.view [ HtmlA.class "import" ]
|
||||
[ Html.p []
|
||||
[ Html.text "Each line will be a different card. Single underscores (“_”) represent slots. If a card "
|
||||
, Html.text "has a slot it will be a call, otherwise a response."
|
||||
]
|
||||
, Html.textarea [ HtmlA.value model.text, HtmlE.onInput (UpdateImportText >> wrap) ] []
|
||||
, Html.div [ HtmlA.class "actions" ]
|
||||
[ Button.view Button.Standard
|
||||
Button.Padded
|
||||
"Cancel"
|
||||
(Icon.arrowLeft |> Icon.viewIcon |> Just)
|
||||
(False |> SetImportVisible |> wrap |> Just)
|
||||
, Button.view Button.Standard
|
||||
Button.Padded
|
||||
"Import"
|
||||
(Icon.fileImport |> Icon.viewIcon |> Just)
|
||||
importAction
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
importedCards : Model -> List ImportedCard
|
||||
importedCards model =
|
||||
let
|
||||
lineToChange line =
|
||||
case String.split "_" line of
|
||||
first :: [] ->
|
||||
Response.fromString first |> ImportedResponse
|
||||
|
||||
other ->
|
||||
Call.fromStrings other |> ImportedCall
|
||||
in
|
||||
model.text |> String.lines |> List.map lineToChange
|
||||
@@ -0,0 +1,13 @@
|
||||
module ManyDecks.Pages.Edit.Import.Model exposing (ImportedCard(..), Model)
|
||||
|
||||
import Cards.Call exposing (Call)
|
||||
import Cards.Response exposing (Response)
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ text : String }
|
||||
|
||||
|
||||
type ImportedCard
|
||||
= ImportedCall Call
|
||||
| ImportedResponse Response
|
||||
@@ -0,0 +1,71 @@
|
||||
module ManyDecks.Pages.Edit.Model exposing
|
||||
( CardChange(..)
|
||||
, CardEditor(..)
|
||||
, Change(..)
|
||||
, EditError(..)
|
||||
, Model
|
||||
, Msg(..)
|
||||
, UpdateEditor(..)
|
||||
)
|
||||
|
||||
import Cards.Call exposing (Call)
|
||||
import Cards.Deck as Deck
|
||||
import Cards.Response exposing (Response)
|
||||
import Http
|
||||
import ManyDecks.Pages.Decks.Deck as Deck
|
||||
import ManyDecks.Pages.Edit.CallEditor.Model as CallEditor
|
||||
import ManyDecks.Pages.Edit.Import.Model as Import
|
||||
|
||||
|
||||
type CardEditor
|
||||
= NameEditor String String
|
||||
| CallEditor Int Call CallEditor.Model
|
||||
| ResponseEditor Int Response Response
|
||||
|
||||
|
||||
type Msg
|
||||
= StartEditing CardEditor
|
||||
| Edit UpdateEditor
|
||||
| EndEditing
|
||||
| Delete
|
||||
| Undo
|
||||
| Redo
|
||||
| SetDeletionEnabled Bool
|
||||
| ApplyChange Change
|
||||
| SetImportVisible Bool
|
||||
| Import
|
||||
| UpdateImportText String
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ code : Deck.Code
|
||||
, deck : Deck.Deck
|
||||
, editing : Maybe CardEditor
|
||||
, changes : List Change
|
||||
, redoStack : List Change
|
||||
, errors : List EditError
|
||||
, deletionEnabled : Bool
|
||||
, importer : Maybe Import.Model
|
||||
}
|
||||
|
||||
|
||||
type UpdateEditor
|
||||
= UpdateName String
|
||||
| UpdateCall CallEditor.Msg
|
||||
| UpdateResponse Response
|
||||
|
||||
|
||||
type Change
|
||||
= ChangeName String String
|
||||
| CallChange (CardChange Call)
|
||||
| ResponseChange (CardChange Response)
|
||||
|
||||
|
||||
type CardChange value
|
||||
= Add Int value
|
||||
| Replace Int value value
|
||||
| Remove Int value
|
||||
|
||||
|
||||
type EditError
|
||||
= ChangeError String (List Change) Bool
|
||||
@@ -0,0 +1,39 @@
|
||||
module ManyDecks.Pages.Login exposing (..)
|
||||
|
||||
import FontAwesome.Icon as Icon
|
||||
import FontAwesome.Solid as Icon
|
||||
import Html
|
||||
import Html.Attributes as HtmlA
|
||||
import ManyDecks.Messages exposing (Msg(..))
|
||||
import Material.Button as Button
|
||||
import Material.Card as Card
|
||||
|
||||
|
||||
view : Html.Html Msg
|
||||
view =
|
||||
Card.view [ HtmlA.class "log-in" ]
|
||||
[ Html.h1 [] [ Icon.boxOpen |> Icon.viewIcon, Html.text "Many Decks" ]
|
||||
, Html.span [ HtmlA.class "version" ] [ Html.text "alpha" ]
|
||||
, Html.p []
|
||||
[ Html.text "Create decks for "
|
||||
, Html.a [ HtmlA.target "_blank", HtmlA.href "https://md.rereadgames.com" ] [ Html.text "Massive Decks" ]
|
||||
, Html.text "."
|
||||
]
|
||||
, Html.p []
|
||||
[ Html.text "This is a very early version, produced quickly in response to Cardcast's demise, there will "
|
||||
, Html.text "likely be bugs. Please report any you find "
|
||||
, Html.a [ HtmlA.target "_blank", HtmlA.href "https://github.com/Lattyware/manydecks" ]
|
||||
[ Html.text "on GitHub" ]
|
||||
]
|
||||
, Html.p []
|
||||
[ Html.text "Currently the data for this service is not backed up! Please keep local copies of your "
|
||||
, Html.text "decks as well, just in case something goes wrong."
|
||||
]
|
||||
, Html.div [ HtmlA.id "google-sign-in" ]
|
||||
[ Button.view Button.Raised
|
||||
Button.Padded
|
||||
"Sign in with Google"
|
||||
(Html.div [ HtmlA.class "google-icon" ] [] |> Just)
|
||||
(Just TryGoogleAuth)
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,172 @@
|
||||
module ManyDecks.Pages.Profile exposing
|
||||
( update
|
||||
, view
|
||||
)
|
||||
|
||||
import FontAwesome.Icon as Icon
|
||||
import FontAwesome.Solid as Icon
|
||||
import Html exposing (Html)
|
||||
import Html.Attributes as HtmlA
|
||||
import Http
|
||||
import Json.Encode
|
||||
import ManyDecks.Auth as Auth exposing (Auth)
|
||||
import ManyDecks.Pages.Profile.Model exposing (..)
|
||||
import Material.Button as Button
|
||||
import Material.Card as Card
|
||||
import Material.Switch as Switch
|
||||
import Material.TextField as TextField
|
||||
|
||||
|
||||
update : msg -> (Auth -> msg) -> (Msg -> msg) -> String -> Msg -> Model -> ( Model, Cmd msg )
|
||||
update signOut updateAuth wrap token msg model =
|
||||
case msg of
|
||||
SetUsername username ->
|
||||
( { model | name = username }, Cmd.none )
|
||||
|
||||
SetViewingProfile viewing ->
|
||||
( { model | viewing = viewing }, Cmd.none )
|
||||
|
||||
SetDeletionEnabled enabled ->
|
||||
( { model | deletionEnabled = enabled }, Cmd.none )
|
||||
|
||||
Save newName ->
|
||||
let
|
||||
handle result =
|
||||
case result of
|
||||
Ok newAuth ->
|
||||
updateAuth newAuth
|
||||
|
||||
Err error ->
|
||||
Error error |> wrap
|
||||
in
|
||||
( model, save token newName handle )
|
||||
|
||||
Delete ->
|
||||
( model, delete signOut token )
|
||||
|
||||
Error error ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
view : (Msg -> msg) -> msg -> Auth -> Model -> List (Html msg)
|
||||
view wrap backup auth model =
|
||||
[ Card.view [ HtmlA.class "profile" ]
|
||||
[ editSection wrap auth model
|
||||
, backupSection backup
|
||||
, deleteSection wrap model
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
editSection : (Msg -> msg) -> Auth -> Model -> Html msg
|
||||
editSection wrap auth { name } =
|
||||
let
|
||||
title =
|
||||
Html.h2 [] [ Html.text "Profile" ]
|
||||
|
||||
editName =
|
||||
TextField.view "Username" TextField.Text name (SetUsername >> wrap |> Just)
|
||||
|
||||
description =
|
||||
Html.p [] [ Html.text "This name will be displayed publicly as the author of any deck you create." ]
|
||||
|
||||
saveAction =
|
||||
if name /= auth.name then
|
||||
name |> Save |> wrap |> Just
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
button =
|
||||
Button.view Button.Raised Button.Padded "Save" (Icon.save |> Icon.viewIcon |> Just) saveAction
|
||||
in
|
||||
Html.div [ HtmlA.class "edit section" ] [ title, editName, description, button ]
|
||||
|
||||
|
||||
backupSection : msg -> Html msg
|
||||
backupSection backup =
|
||||
let
|
||||
title =
|
||||
Html.h3 [] [ Html.text "Backup" ]
|
||||
|
||||
description =
|
||||
Html.p [] [ Html.text "Download a zip archive of all of your decks." ]
|
||||
|
||||
button =
|
||||
Button.view
|
||||
Button.Raised
|
||||
Button.Padded
|
||||
"Backup Decks"
|
||||
(Icon.download |> Icon.viewIcon |> Just)
|
||||
(backup |> Just)
|
||||
in
|
||||
Html.div [ HtmlA.class "backup section" ] [ title, description, button ]
|
||||
|
||||
|
||||
deleteSection : (Msg -> msg) -> Model -> Html msg
|
||||
deleteSection wrap { deletionEnabled } =
|
||||
let
|
||||
title =
|
||||
Html.h3 [] [ Html.text "Deletion" ]
|
||||
|
||||
warning =
|
||||
Html.p []
|
||||
[ Html.text "This will "
|
||||
, Html.strong [] [ Html.text "permanently delete" ]
|
||||
, Html.text " your profile and "
|
||||
, Html.em [] [ Html.text "all your decks" ]
|
||||
, Html.text ". Once done, there is "
|
||||
, Html.em [] [ Html.text "no way" ]
|
||||
, Html.text " to recover that data. We highly recommend you do a backup before this."
|
||||
]
|
||||
|
||||
sureSwitch =
|
||||
Switch.view
|
||||
(Html.span [] [ Html.text "I am sure that I want to permanently delete my profile and all my decks." ])
|
||||
deletionEnabled
|
||||
(SetDeletionEnabled >> wrap |> Just)
|
||||
|
||||
deleteAction =
|
||||
if deletionEnabled then
|
||||
Delete |> wrap |> Just
|
||||
|
||||
else
|
||||
Nothing
|
||||
|
||||
deleteButton =
|
||||
Button.view
|
||||
Button.Unelevated
|
||||
Button.Padded
|
||||
"Delete Profile"
|
||||
(Icon.trash |> Icon.viewIcon |> Just)
|
||||
deleteAction
|
||||
in
|
||||
Html.div [ HtmlA.class "delete section" ]
|
||||
[ title, warning, sureSwitch, deleteButton ]
|
||||
|
||||
|
||||
save : String -> String -> (Result Http.Error Auth.Auth -> msg) -> Cmd msg
|
||||
save token name toMsg =
|
||||
Http.post
|
||||
{ url = "/api/users"
|
||||
, body =
|
||||
[ ( "token", token |> Json.Encode.string )
|
||||
, ( "name", name |> Json.Encode.string )
|
||||
]
|
||||
|> Json.Encode.object
|
||||
|> Http.jsonBody
|
||||
, expect = Http.expectJson toMsg Auth.decoder
|
||||
}
|
||||
|
||||
|
||||
delete : msg -> String -> Cmd msg
|
||||
delete signOut token =
|
||||
Http.request
|
||||
{ method = "DELETE"
|
||||
, headers = []
|
||||
, url = "/api/users"
|
||||
, body = [ ( "token", token |> Json.Encode.string ) ] |> Json.Encode.object |> Http.jsonBody
|
||||
, expect = Http.expectWhatever (always signOut)
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
module ManyDecks.Pages.Profile.Model exposing
|
||||
( Model
|
||||
, Msg(..)
|
||||
, init
|
||||
)
|
||||
|
||||
import Http
|
||||
import ManyDecks.Auth exposing (Auth)
|
||||
|
||||
|
||||
type Msg
|
||||
= SetUsername String
|
||||
| SetViewingProfile Bool
|
||||
| SetDeletionEnabled Bool
|
||||
| Delete
|
||||
| Save String
|
||||
| Error Http.Error
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ viewing : Bool
|
||||
, deletionEnabled : Bool
|
||||
, name : String
|
||||
}
|
||||
|
||||
|
||||
init : Auth -> Model
|
||||
init auth =
|
||||
{ viewing = False
|
||||
, deletionEnabled = False
|
||||
, name = auth.name
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
port module ManyDecks.Ports exposing (..)
|
||||
|
||||
import Json.Decode as Json
|
||||
import ManyDecks.Auth as Auth
|
||||
|
||||
|
||||
port tryGoogleAuth : () -> Cmd msg
|
||||
|
||||
|
||||
port googleAuthResult : (Json.Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
port json5Decode : String -> Cmd msg
|
||||
|
||||
|
||||
port json5Decoded : (Json.Value -> msg) -> Sub msg
|
||||
|
||||
|
||||
port storeAuth : Maybe Auth.Auth -> Cmd msg
|
||||
|
||||
|
||||
port copy : String -> Cmd msg
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
type Token = string;
|
||||
|
||||
interface Auth {
|
||||
token: Token;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Flags {
|
||||
auth?: Auth;
|
||||
}
|
||||
|
||||
type GoogleAuthResult = { code: string } | { error: string };
|
||||
|
||||
export interface InboundPort<T> {
|
||||
subscribe(callback: (data: T) => void): void;
|
||||
}
|
||||
|
||||
export interface OutboundPort<T> {
|
||||
send(data: T): void;
|
||||
}
|
||||
|
||||
export namespace Elm {
|
||||
namespace ManyDecks {
|
||||
export interface App {
|
||||
ports: {
|
||||
tryGoogleAuth: InboundPort<null>;
|
||||
googleAuthResult: OutboundPort<GoogleAuthResult>;
|
||||
json5Decode: InboundPort<string>;
|
||||
json5Decoded: OutboundPort<object>;
|
||||
storeAuth: InboundPort<Auth | undefined>;
|
||||
copy: InboundPort<string>;
|
||||
};
|
||||
}
|
||||
export function init(options: {
|
||||
node?: HTMLElement | null;
|
||||
flags: Flags;
|
||||
}): Elm.ManyDecks.App;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Many Decks</title>
|
||||
<meta name="description" content="Build decks for Massive Decks." />
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="../../assets/images/icon.svg" sizes="192x192">
|
||||
<link rel="icon" type="image/png" href="../../assets/images/icon.png" sizes="64x64">
|
||||
<meta name="theme-color" content="#00796b">
|
||||
|
||||
<script src="https://apis.google.com/js/platform.js?onload=init" async defer></script>
|
||||
|
||||
<link href="../scss/many-decks.scss" type="text/css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
.decks {
|
||||
min-width: 18em;
|
||||
max-width: 30em;
|
||||
margin: 3em;
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
.deck {
|
||||
margin: 0.5em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.deck-code {
|
||||
margin-right: 1em;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-shrink: 1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
min-width: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-left: 1em;
|
||||
|
||||
.calls {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.calls,
|
||||
.responses {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
@use "./edit/import";
|
||||
|
||||
.edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 3em;
|
||||
|
||||
.cards {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.calls,
|
||||
.responses {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
flex-basis: 50%;
|
||||
|
||||
li {
|
||||
padding: 0.25em;
|
||||
cursor: pointer;
|
||||
min-height: 1.2em;
|
||||
|
||||
&:empty::after {
|
||||
display: block;
|
||||
content: "(Click To Edit)";
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.add {
|
||||
&:hover {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calls {
|
||||
color: #ffffff;
|
||||
background-color: #000000;
|
||||
|
||||
li {
|
||||
&:hover {
|
||||
background-color: #333333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.responses {
|
||||
li {
|
||||
&:hover {
|
||||
background-color: #cccccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.delete {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
mwc-button {
|
||||
--mdc-theme-primary: #ff0000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
overflow: auto;
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
|
||||
background-color: transparentize(#cccccc, 0.7);
|
||||
}
|
||||
|
||||
.errors {
|
||||
.change {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editing {
|
||||
user-select: none;
|
||||
|
||||
.cursor {
|
||||
border-left: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: #4285f4;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
display: block;
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
height: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.editing-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
.log-in {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
min-width: 18em;
|
||||
max-width: 30em;
|
||||
margin: 3em;
|
||||
|
||||
position: relative;
|
||||
|
||||
h1 {
|
||||
margin: 0.5em;
|
||||
|
||||
> svg {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.version {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin-right: -1em;
|
||||
margin-top: -1.5em;
|
||||
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
|
||||
background-color: #d81b60;
|
||||
color: #ffffff;
|
||||
|
||||
border-radius: 1.5em;
|
||||
|
||||
font-size: 1.5em;
|
||||
|
||||
transform: rotateZ(10deg);
|
||||
|
||||
box-shadow: 0.15em 0.15em 0.4em transparentize(#000000, 0.7),
|
||||
-0.05em -0.05em 0.4em transparentize(#000000, 0.7);
|
||||
|
||||
transition: box-shadow 0.5s, margin-top 0.5s;
|
||||
|
||||
&:hover {
|
||||
margin-top: -2em;
|
||||
box-shadow: 0.3em 0.3em 0.4em transparentize(#000000, 0.7),
|
||||
-0.05em -0.05em 0.4em transparentize(#000000, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#google-sign-in mwc-button {
|
||||
--mdc-theme-primary: #4285f4;
|
||||
--mdc-theme-on-primary: #ffffff;
|
||||
--mdc-button-horizontal-padding: 0.5em;
|
||||
--mdc-shape-small: 0;
|
||||
margin: 0.5em;
|
||||
|
||||
> span {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
background-image: url("../../assets/images/google/normal.svg");
|
||||
background-size: 100% 100%;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
margin: 0 0.5em 0 -1em;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
&:disabled .google-icon {
|
||||
background-image: url("../../assets/images/google/disabled.svg");
|
||||
}
|
||||
|
||||
&:focus .google-icon {
|
||||
background-image: url("../../assets/images/google/focus.svg");
|
||||
}
|
||||
|
||||
&:active .google-icon {
|
||||
background-image: url("../../assets/images/google/pressed.svg");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin: 3em;
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0.5em;
|
||||
}
|
||||
|
||||
.delete {
|
||||
--mdc-theme-primary: #ff0000;
|
||||
|
||||
mwc-button {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
@use "./_colors";
|
||||
@use "./_size";
|
||||
|
||||
.game-card {
|
||||
@include size.fluid-type(20rem, 64rem, 0.5rem, 0.8rem);
|
||||
|
||||
display: inline-block;
|
||||
|
||||
transition: transform 1s;
|
||||
transform: rotateY(0turn);
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: right center;
|
||||
|
||||
text-rendering: optimizeLegibility;
|
||||
font-family: "Helvetica Neue", "Nimbus Sans L", sans-serif;
|
||||
font-weight: bold;
|
||||
|
||||
$up-side: "&:not(.face-down) .side.face, &.face-down .side.reverse";
|
||||
$down-side: "&.face-down .side.face, &:not(.face-down) .side.reverse";
|
||||
|
||||
--width: #{size.$card-width};
|
||||
--aspect-ratio: #{size.$full-size};
|
||||
|
||||
&.response {
|
||||
--bg: #{colors.$response};
|
||||
--fg: #{colors.$on-response};
|
||||
|
||||
&.immutable .primary-content {
|
||||
&::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: ".";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.call {
|
||||
--bg: #{colors.$call};
|
||||
--fg: #{colors.$on-call};
|
||||
|
||||
.primary-content {
|
||||
p {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: stretch;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
|
||||
span {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.slot {
|
||||
display: inline flex;
|
||||
flex-grow: 1;
|
||||
flex-basis: 2em;
|
||||
max-width: 14em;
|
||||
|
||||
&.filled {
|
||||
display: contents;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.empty {
|
||||
flex-grow: 1;
|
||||
border-bottom: 0.075em solid var(--fg);
|
||||
margin-bottom: 0.2em; // line-height - font-size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.side {
|
||||
width: var(--width);
|
||||
height: calc(var(--width) * (1 / var(--aspect-ratio)));
|
||||
|
||||
backface-visibility: hidden;
|
||||
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border-radius: 0.75em;
|
||||
border: 0.3em solid var(--bg);
|
||||
box-shadow: 0.15em 0.15em 0.4em rgba(0, 0, 0, 0.3),
|
||||
-0.05em -0.05em 0.4em rgba(0, 0, 0, 0.3);
|
||||
|
||||
&.face {
|
||||
transform: rotateY(0turn);
|
||||
}
|
||||
|
||||
&.reverse {
|
||||
transform: rotateY(0.5turn);
|
||||
|
||||
.primary-content {
|
||||
font-size: 3em;
|
||||
line-height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.primary-content > textarea {
|
||||
display: block;
|
||||
font: inherit;
|
||||
background-color: inherit;
|
||||
resize: none;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.primary-content {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
font-size: 1.6em;
|
||||
line-height: 1.4em;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.secondary-content {
|
||||
margin-top: 1em;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
font-size: 0.9em;
|
||||
max-width: 100%;
|
||||
|
||||
.source {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
font-size: 0.7em;
|
||||
overflow: hidden;
|
||||
margin-right: 0.5em;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
background-image: url(../../../assets/images/deck.svg);
|
||||
background-size: 100% 100%;
|
||||
display: inline-block;
|
||||
width: 3.36em;
|
||||
height: 2.4em;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex-shrink: 1;
|
||||
height: 100%;
|
||||
min-width: 1em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.instructions {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 1.4em;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instruction {
|
||||
&:before,
|
||||
&:after {
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.face-down {
|
||||
transform: translateX(-100%) rotateY(-0.5turn);
|
||||
}
|
||||
|
||||
#{$down-side} {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&.content-sized {
|
||||
.side {
|
||||
height: auto;
|
||||
width: auto;
|
||||
|
||||
max-width: var(--width);
|
||||
max-height: calc(var(--width) * (1 / var(--aspect-ratio)));
|
||||
}
|
||||
|
||||
#{$up-side} {
|
||||
position: relative;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 1.75em 0 0 1.75em;
|
||||
box-shadow: none;
|
||||
|
||||
.primary-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.secondary-content {
|
||||
position: absolute;
|
||||
|
||||
width: var(--width);
|
||||
height: calc(var(--width) * (1 / var(--aspect-ratio)));
|
||||
|
||||
margin: -1.75em 0 0 -1.75em;
|
||||
|
||||
padding: 1em;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border-radius: 0.75em;
|
||||
border: 0.3em solid var(--bg);
|
||||
box-shadow: 0.15em 0.15em 0.4em rgba(0, 0, 0, 0.3),
|
||||
-0.05em -0.05em 0.4em rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instruction {
|
||||
white-space: nowrap;
|
||||
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-rendering: optimizeLegibility;
|
||||
|
||||
&:before {
|
||||
content: "[";
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: "]";
|
||||
}
|
||||
}
|
||||
|
||||
.amount {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
border-radius: 50%;
|
||||
background: var(--fg, colors.$call);
|
||||
color: var(--bg, colors.$on-call);
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.capitalize::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.upper-case {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
$on-call: #ffffff;
|
||||
$call: #000000;
|
||||
|
||||
$on-response: $call;
|
||||
$response: $on-call;
|
||||
@@ -0,0 +1,25 @@
|
||||
$card-width: 18em;
|
||||
|
||||
$full-size: (5 / 7);
|
||||
$square: 1;
|
||||
|
||||
@mixin pad-to-aspect-ratio($ratio) {
|
||||
padding-bottom: #{calc(1 / #{$ratio} * 100%)};
|
||||
}
|
||||
|
||||
@function strip-unit($value) {
|
||||
@return $value / ($value * 0 + 1);
|
||||
}
|
||||
|
||||
@mixin fluid-type($min-vw, $max-vw, $min-font-size, $max-font-size) {
|
||||
font-size: $min-font-size;
|
||||
@media screen and (min-width: $min-vw) {
|
||||
font-size: calc(
|
||||
#{$min-font-size} + #{strip-unit($max-font-size - $min-font-size)} *
|
||||
((100vw - #{$min-vw}) / #{strip-unit($max-vw - $min-vw)})
|
||||
);
|
||||
}
|
||||
@media screen and (min-width: $max-vw) {
|
||||
font-size: $max-font-size;
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
.import {
|
||||
flex-grow: 1;
|
||||
|
||||
align-items: stretch;
|
||||
|
||||
margin: 3em;
|
||||
|
||||
textarea {
|
||||
flex-grow: 1;
|
||||
height: 20em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
@use "../../elm-material/src/scss/_material";
|
||||
|
||||
@use "./_edit";
|
||||
@use "./_profile";
|
||||
@use "./_decks";
|
||||
@use "./_login";
|
||||
@use "./cards/_card";
|
||||
|
||||
:root {
|
||||
font-size: 1.2em;
|
||||
|
||||
--mdc-theme-primary: #00796b;
|
||||
--mdc-theme-secondary: #d81b60;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
|
||||
background-color: #212121;
|
||||
|
||||
font-family: "Helvetica Neue", "Nimbus Sans L", sans-serif;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.deck-code {
|
||||
font-family: monospace;
|
||||
background-color: #00796b;
|
||||
color: #ffffff;
|
||||
padding: 0.5em;
|
||||
letter-spacing: 0.2em;
|
||||
border: none;
|
||||
flex-basis: 5em;
|
||||
min-width: 5em;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#sign-out {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
#view-profile {
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
mwc-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.core-error {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
background-color: transparentize(#ff0000, 0.7);
|
||||
|
||||
font-size: 3em;
|
||||
|
||||
z-index: 999;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.mdc-card {
|
||||
margin: 3em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "../../elm-material/src/ts/material";
|
||||
import * as Json5 from "json5";
|
||||
import { Elm } from "src/elm/ManyDecks";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
init: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const { Elm } = await import(
|
||||
/* webpackChunkName: "many-decks" */ "../elm/ManyDecks"
|
||||
);
|
||||
|
||||
const savedAuth = localStorage.getItem("auth");
|
||||
|
||||
const app: Elm.ManyDecks.App = Elm.ManyDecks.init({
|
||||
flags: {
|
||||
auth: savedAuth === null ? null : JSON.parse(savedAuth),
|
||||
},
|
||||
});
|
||||
|
||||
app.ports.tryGoogleAuth.subscribe((_) => {
|
||||
if (gapi.auth2 !== undefined) {
|
||||
gapi.auth2.authorize(
|
||||
{
|
||||
client_id: "CHANGE ME",
|
||||
scope: "profile openid",
|
||||
response_type: "id_token",
|
||||
},
|
||||
(response) => {
|
||||
app.ports.googleAuthResult.send(
|
||||
response.error !== undefined
|
||||
? { error: response.error }
|
||||
: { code: response.id_token }
|
||||
);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
app.ports.googleAuthResult.send({
|
||||
error: "Could not connect to Google.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.ports.json5Decode.subscribe((raw) => {
|
||||
app.ports.json5Decoded.send(Json5.parse(raw));
|
||||
});
|
||||
|
||||
app.ports.storeAuth.subscribe((auth) => {
|
||||
if (auth !== undefined) {
|
||||
localStorage.setItem("auth", JSON.stringify(auth));
|
||||
} else {
|
||||
localStorage.removeItem("auth");
|
||||
}
|
||||
});
|
||||
|
||||
app.ports.copy.subscribe((id) => {
|
||||
const textField = document.getElementById(id);
|
||||
if (textField !== null && textField instanceof HTMLInputElement) {
|
||||
textField.select();
|
||||
const value = textField.value;
|
||||
textField.setSelectionRange(0, value.length);
|
||||
try {
|
||||
navigator.clipboard.writeText(value).catch(console.error);
|
||||
} catch (err) {
|
||||
document.execCommand("copy");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
window.init = () => {
|
||||
gapi.load("auth2", () => {});
|
||||
};
|
||||
if (window["gapi"] !== undefined) {
|
||||
window.init();
|
||||
}
|
||||
Reference in New Issue
Block a user