Initial version.

This commit is contained in:
Gareth Latty
2020-05-21 02:56:35 +01:00
commit 12880b9fa3
74 changed files with 20643 additions and 0 deletions
+187
View File
@@ -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
+115
View File
@@ -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
+9
View File
@@ -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
+53
View File
@@ -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
+64
View File
@@ -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
+74
View File
@@ -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 []
]
+64
View File
@@ -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)
+71
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
module Cards.Type exposing (Type(..))
type Type value
= Call
| Response
+391
View File
@@ -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
+25
View File
@@ -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
+61
View File
@@ -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
+32
View File
@@ -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
}
+37
View File
@@ -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
+164
View File
@@ -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
+488
View File
@@ -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
+39
View File
@@ -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)
]
]
+172
View File
@@ -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
}
+22
View File
@@ -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
+39
View File
@@ -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;
}
}
+22
View File
@@ -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>
+70
View File
@@ -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;
}
}
}
}
}
+132
View File
@@ -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;
}
+95
View File
@@ -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");
}
}
+20
View File
@@ -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;
}
}
}
+283
View File
@@ -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;
}
+5
View File
@@ -0,0 +1,5 @@
$on-call: #ffffff;
$call: #000000;
$on-response: $call;
$response: $on-call;
+25
View File
@@ -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;
}
}
+12
View File
@@ -0,0 +1,12 @@
.import {
flex-grow: 1;
align-items: stretch;
margin: 3em;
textarea {
flex-grow: 1;
height: 20em;
}
}
+90
View File
@@ -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;
}
}
+81
View File
@@ -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();
}