diff --git a/www/src/App.elm b/www/src/App.elm index a91d379..1bdf62e 100644 --- a/www/src/App.elm +++ b/www/src/App.elm @@ -21,244 +21,101 @@ import Maybe.Extra import Paginated import Spinner import Task +import UI import Url import Url.Builder import Url.Parser exposing ((), ()) import Url.Parser.Query -type alias Dimensions = - { width : Int - , height : Int - } - - -type alias Criteria = - { query : String - , sortBy : String - , ownedOnly : Bool - } - - type alias Model = { navigationKey : Browser.Navigation.Key - , viewport : Dimensions + , url : Url.Url + , viewport : UI.Dimensions , device : E.Device - , spinner : Spinner.Model - , criteria : Criteria - , cardPage : CardPage - , activeCard : Maybe Card.Card - , collectionStatistics : Maybe Collection.Statistics + , route : Route + , page : Page } type Msg = UrlChanged Url.Url - | ViewportChanged Dimensions + | ViewportChanged UI.Dimensions | LinkClicked Browser.UrlRequest + | CollectionMsg Collection.Msg | SpinnerMsg Spinner.Msg - | UpdateCriteria CriteriaMsg - | Search - | GetPage Url.Url - | GotStatistics (Result Http.Error Collection.Statistics) - | FoundCards (Result Http.Error (Paginated.Page Card.Card)) - | ShowCardDetails Card.Card - | ClearCardDetails -type CriteriaMsg - = UpdateName String - | UpdateSortBy String - | UpdateOwnedOnly Bool +type Page + = NotFound + | Collection Collection.Model + | Decks -type CardPage - = Ready (Paginated.Page Card.Card) - | Loading (Paginated.Page Card.Card) - | Failed +type Route + = Home + | MyCollection + | MyDecks -toLoading : CardPage -> CardPage -toLoading cardPage = - case cardPage of - Ready page -> - Loading page +routeToUrl : Route -> String +routeToUrl route = + case route of + Home -> + Url.Builder.absolute [] [] - Loading page -> - Loading page + MyCollection -> + Url.Builder.absolute [ "collection" ] [] - Failed -> - Loading Paginated.empty - - -manaSpinner : Spinner.Config -manaSpinner = - let - color index = - if index < 1.0 then - Color.red - - else if index < 2.0 then - Color.green - - else if index < 3.0 then - Color.purple - - else if index < 4.0 then - Color.blue - - else - Color.white - - default = - Spinner.defaultConfig - in - { default - | lines = 5.0 - , length = 0.0 - , width = 20 - , color = color - } - - -searchQuery : Criteria -> List Url.Builder.QueryParameter -searchQuery criteria = - [ Url.Builder.string "q" criteria.query - , Url.Builder.string "sort_by" criteria.sortBy - , Url.Builder.string "in_collection" - (if criteria.ownedOnly then - "yes" - - else - "no" - ) - ] - - -search : Criteria -> Cmd Msg -search criteria = - loadPage <| - Url.Builder.absolute - [ "api", "search" ] - (Url.Builder.int "limit" 18 :: searchQuery criteria) - - -loadPage : String -> Cmd Msg -loadPage url = - Http.get - { url = url - , expect = Paginated.expectJson FoundCards Card.decode - } - - -getCollectionStatistics : Cmd Msg -getCollectionStatistics = - Http.get - { url = Url.Builder.absolute [ "api", "collection" ] [] - , expect = Http.expectJson GotStatistics Collection.decodeStatistics - } - - -parseUrl : Url.Parser.Parser (Criteria -> a) a -parseUrl = - let - query = - Url.Parser.Query.string "q" - |> Url.Parser.Query.map (Maybe.withDefault "") - - sortBy = - Url.Parser.Query.enum "sort_by" - (Dict.fromList - [ ( "rarity", "rarity" ) - , ( "price", "price" ) - ] - ) - |> Url.Parser.Query.map (Maybe.withDefault "rarity") - - inCollection = - Url.Parser.Query.enum "in_collection" - (Dict.fromList - [ ( "true", True ) - , ( "false", False ) - , ( "yes", True ) - , ( "no", False ) - ] - ) - |> Url.Parser.Query.map (Maybe.withDefault True) - in - Url.Parser.top Url.Parser.Query.map3 Criteria query sortBy inCollection - - -criteriaFromUrl : Url.Url -> Criteria -criteriaFromUrl url = - let - emptyCriteria = - { query = "", sortBy = "price", ownedOnly = True } - in - Url.Parser.parse parseUrl url - |> Maybe.withDefault emptyCriteria + MyDecks -> + Url.Builder.absolute [ "decks" ] [] init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) init _ url key = let - criteria = - criteriaFromUrl url - viewport = { width = 1280, height = 720 } + + device = + E.classifyDevice viewport + + ( pageModel, pageCmd ) = + Collection.init key url device in ( { navigationKey = key + , url = url , viewport = viewport - , device = E.classifyDevice viewport - , spinner = Spinner.init - , criteria = criteria - , cardPage = Loading Paginated.empty - , activeCard = Nothing - , collectionStatistics = Nothing + , device = device + , route = MyCollection + , page = Collection pageModel } , Cmd.batch - [ search criteria - , getCollectionStatistics - , Task.perform - (\x -> - ViewportChanged - { width = floor x.viewport.width - , height = floor x.viewport.height - } - ) - Browser.Dom.getViewport + [ UI.getViewport ViewportChanged + , Cmd.map CollectionMsg pageCmd ] ) -updateCriteria : CriteriaMsg -> Criteria -> Criteria -updateCriteria msg model = - case msg of - UpdateName text -> - { model | query = text } - - UpdateSortBy column -> - { model | sortBy = column } - - UpdateOwnedOnly value -> - { model | ownedOnly = value } +updateWith : (pageModel -> Page) -> (pageMsg -> Msg) -> Model -> ( pageModel, Cmd pageMsg ) -> ( Model, Cmd Msg ) +updateWith pageType pageMsg model ( subModel, subCmd ) = + ( { model | page = pageType subModel }, Cmd.map pageMsg subCmd ) update : Msg -> Model -> ( Model, Cmd Msg ) update msg model = - case msg of - UrlChanged url -> - let - criteria = - Debug.log "criteria" <| criteriaFromUrl url - in - ( { model | criteria = criteria }, search criteria ) + case ( msg, model.page ) of + ( UrlChanged url, Collection pageModel ) -> + Collection.update (Collection.UrlChanged url) pageModel + |> updateWith Collection CollectionMsg model - LinkClicked _ -> + ( UrlChanged url, _ ) -> ( model, Cmd.none ) - ViewportChanged viewport -> + ( LinkClicked _, _ ) -> + ( model, Cmd.none ) + + ( ViewportChanged viewport, _ ) -> ( { model | viewport = viewport , device = E.classifyDevice viewport @@ -266,496 +123,62 @@ update msg model = , Cmd.none ) - SpinnerMsg msg_ -> - ( { model | spinner = Spinner.update msg_ model.spinner }, Cmd.none ) + ( SpinnerMsg spinnerMsg, Collection pageModel ) -> + Collection.update (Collection.SpinnerMsg spinnerMsg) pageModel + |> updateWith Collection CollectionMsg model - UpdateCriteria msg_ -> - let - newCriteria = - updateCriteria msg_ model.criteria - in - case msg_ of - UpdateName _ -> - ( { model | criteria = newCriteria } - , Cmd.none - ) + ( CollectionMsg pageMsg, Collection pageModel ) -> + Collection.update pageMsg pageModel + |> updateWith Collection CollectionMsg model - UpdateSortBy _ -> - update Search { model | criteria = newCriteria } + ( _, _ ) -> + ( model, Cmd.none ) - UpdateOwnedOnly _ -> - update Search { model | criteria = newCriteria } - Search -> - ( { model - | cardPage = toLoading model.cardPage - , activeCard = Nothing - } - , Cmd.batch - [ Browser.Navigation.pushUrl model.navigationKey <| - Url.Builder.relative [] (searchQuery model.criteria) +navBar : Model -> E.Element Msg +navBar model = + let + navLink : Route -> String -> E.Element Msg + navLink route text = + E.link + [ E.pointer + , E.padding 10 + , Font.center + , Background.color UI.colors.primary ] - ) - - GetPage url -> - ( { model | cardPage = toLoading model.cardPage }, loadPage (Url.toString url) ) - - GotStatistics (Ok statistics) -> - ( { model | collectionStatistics = Just statistics }, Cmd.none ) - - GotStatistics (Err _) -> - ( model, Cmd.none ) - - FoundCards (Ok cardPage) -> - ( { model | cardPage = Ready cardPage }, Cmd.none ) - - FoundCards (Err _) -> - ( model, Cmd.none ) - - ShowCardDetails card -> - ( { model | activeCard = Just card }, Cmd.none ) - - ClearCardDetails -> - ( { model | activeCard = Nothing }, Cmd.none ) - - -colors = - let - blue = - E.rgb255 100 100 255 - - slate = - E.rgb255 150 150 200 - - lighterGrey = - E.rgb255 60 60 60 - - lightGrey = - E.rgb255 50 50 50 - - grey = - E.rgb255 40 40 40 - - darkGrey = - E.rgb255 30 30 30 - - darkerGrey = - E.rgb255 20 20 20 - - white = - E.rgb255 255 255 255 - - offwhite = - E.rgb255 200 200 200 - - mythic = - E.rgb255 205 55 0 - - rare = - E.rgb255 218 165 32 - - uncommon = - E.rgb255 112 128 144 - - common = - E.rgb255 47 79 79 + { url = routeToUrl route, label = E.text text } in - { primary = blue - , secondary = slate - , background = lightGrey - , navBar = darkerGrey - , sidebar = lighterGrey - , selected = darkGrey - , hover = grey - , title = white - , subtitle = offwhite - , text = offwhite - , mythic = mythic - , rare = rare - , uncommon = uncommon - , common = common - } - - -isMobile : E.Device -> Bool -isMobile device = - case device.orientation of - E.Landscape -> - False - - E.Portrait -> - True - - -searchBar : Model -> E.Element Msg -searchBar model = - let - alignment = - if isMobile model.device then - E.column - - else - E.row - in - alignment + E.row [ E.padding 10 , E.spacing 10 , E.width E.fill - , Background.color colors.navBar + , Background.color UI.colors.navBar + , Font.color UI.colors.text ] - [ E.row [ E.spacing 10, E.width E.fill ] - [ Input.text - [ onEnter Search - , Background.color colors.background - , Font.color colors.text - , E.width (E.fill |> E.minimum 150) - ] - { onChange = UpdateCriteria << UpdateName - , text = model.criteria.query - , placeholder = Nothing - , label = Input.labelHidden "Search Input" - } - , Input.button - [ Background.color colors.primary - , Font.color colors.text - , Border.rounded 10 - , E.padding 10 - ] - { onPress = Just Search - , label = E.text "Search" - } - ] - , E.row [ E.spacing 10 ] - [ Input.radio [ E.padding 10 ] - { onChange = UpdateCriteria << UpdateSortBy - , selected = Just model.criteria.sortBy - , label = Input.labelLeft [ Font.color colors.text ] (E.text "Sort by") - , options = - [ Input.option "price" <| E.el [ Font.color colors.text ] <| E.text "Price DESC" - , Input.option "rarity" <| E.el [ Font.color colors.text ] <| E.text "Rarity DESC" - ] - } + [ E.el [ E.width <| E.fillPortion 1 ] <| E.text "Tutor" + , E.row [ E.width <| E.fillPortion 4 ] + [ navLink MyCollection "Collection" + + -- , navLink "" "Decks" ] ] -viewCardBrowser : Model -> E.Element Msg -viewCardBrowser model = - let - viewCard : Dimensions -> Card.Card -> E.Element Msg - viewCard dimensions cardModel = - E.el - [ Border.rounded 10 - , E.clip - , E.width <| E.px dimensions.width - , E.height <| E.px dimensions.height - ] - <| - E.image - [ E.width <| E.px dimensions.width - , E.height <| E.px dimensions.height - , E.behindContent <| - E.html <| - Spinner.view manaSpinner model.spinner - ] - { src = - Url.Builder.crossOrigin "https://api.scryfall.com" - [ "cards", cardModel.scryfallId ] - [ Url.Builder.string "format" "image" - , Url.Builder.string "version" "border_crop" - ] - , description = cardModel.name - } - - badge color foil text = - E.el - [ Border.rounded 5 - , Border.color color - , Border.width 1 - , E.width <| E.px 60 - , Font.family [ Font.typeface "sans" ] - , Font.size 10 - , Font.color colors.title - ] - <| - E.row [ E.height E.fill, E.width E.fill ] - [ E.el [ E.padding 2, E.width E.fill ] <| E.text text - , E.row [ E.padding 1, E.height E.fill, E.width E.fill, Background.color color ] - [ if foil then - E.el - [ E.width E.fill - , E.height E.fill - , Background.gradient - { angle = 4.0 - , steps = - [ E.rgb 148 0 211 - , E.rgb 75 0 130 - , E.rgb 0 0 255 - , E.rgb 0 255 0 - , E.rgb 255 255 0 - , E.rgb 255 127 0 - , E.rgb 255 0 0 - ] - } - ] - E.none - - else - E.none - ] - ] - - setBadge : Card.Card -> E.Element Msg - setBadge card = - let - color = - case card.rarity of - "mythic" -> - colors.mythic - - "rare" -> - colors.rare - - "uncommon" -> - colors.uncommon - - _ -> - colors.common - in - badge color card.foil card.setCode - - priceBadge { currency, amount } = - E.el - [ Border.rounded 5 - , Border.color colors.text - , E.width <| E.px 60 - , E.padding 2 - , Font.family [ Font.typeface "sans" ] - , Font.size 10 - ] - <| - E.row [ E.width E.fill ] - [ E.el [ E.width <| E.fillPortion 1 ] <| E.text <| String.toUpper currency - , E.el [ E.width <| E.fillPortion 2, Font.alignRight ] <| E.text amount - ] - - prices card = - Maybe.Extra.values - [ Maybe.map (\usd -> { currency = "usd", amount = usd }) <| - Maybe.Extra.or card.prices.usd card.prices.usd_foil - , Maybe.map (\eur -> { currency = "eur", amount = eur }) <| - Maybe.Extra.or card.prices.eur card.prices.eur_foil - , Maybe.map (\tix -> { currency = "tix", amount = tix }) card.prices.tix - ] - - cardDetails card = - E.column - [ E.spacing 20 - , E.padding 10 - ] - <| - E.el [ E.centerX ] - (viewCard { width = 192, height = 272 } card) - :: (E.row [ E.spacing 5, E.centerX ] <| List.map priceBadge (prices card)) - :: E.paragraph [ Font.heavy, Font.size 24, Font.center, Font.color colors.title ] [ E.text card.name ] - :: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ]) - (String.lines card.oracleText) - - cardRow : Maybe Card.Card -> Card.Card -> E.Element Msg - cardRow activeCard cardModel = - let - interactiveAttributes = - if activeCard == Just cardModel then - [ Background.color colors.selected ] - - else - [ E.pointer - , E.mouseOver [ Background.color colors.hover ] - , Events.onClick <| ShowCardDetails cardModel - ] - in - E.row - ([ E.width E.fill - , E.spacing 10 - , E.padding 3 - ] - ++ interactiveAttributes - ) - [ E.el [ E.width <| E.px 100 ] <| - E.image - [ E.height <| E.px 60 - , E.centerX - ] - { src = - Url.Builder.crossOrigin "https://api.scryfall.com" - [ "cards", cardModel.scryfallId ] - [ Url.Builder.string "format" "image" - , Url.Builder.string "version" "art_crop" - ] - , description = cardModel.name - } - , E.column [ E.centerY, E.height E.fill, E.width E.fill, E.clipX ] - [ E.el [ Font.color colors.title ] <| E.text cardModel.name - , E.el [ Font.size 16, Font.italic, Font.color colors.subtitle ] <| E.text cardModel.collection - ] - , E.column [ E.alignRight, E.height E.fill ] <| - setBadge cardModel - :: List.map priceBadge (prices cardModel) - ] - - details = - if isMobile model.device then - case model.activeCard of - Just card -> - E.column - [ E.spacing 10 - , E.padding 10 - , E.height <| E.fillPortion 1 - , E.width E.fill - , Background.color colors.sidebar - , E.scrollbarY - ] - <| - E.paragraph [ Font.heavy, Font.size 24, Font.center ] [ E.text card.name ] - :: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ]) - (String.lines card.oracleText) - - Nothing -> - E.none - - else - E.el - [ E.alignTop - , E.width <| E.fillPortion 1 - , E.height E.fill - , Background.color colors.sidebar - ] - (Maybe.map - cardDetails - model.activeCard - |> Maybe.withDefault - E.none - ) - - closedetails = - case model.activeCard of - Just _ -> - Input.button - [ E.height (E.px 30) - , E.width E.fill - , Background.color colors.secondary - , Border.rounded 5 - , Font.color colors.text - , Font.center - ] - { label = E.text "Close", onPress = Just ClearCardDetails } - - Nothing -> - E.none - - navButton text maybeUrl = - case maybeUrl of - Just url -> - Input.button - [ E.height (E.px 30) - , E.width E.fill - , Background.color colors.primary - , Border.rounded 5 - , Font.color colors.text - , Font.center - ] - { label = E.text text, onPress = Just (GetPage url) } - - Nothing -> - E.el [ E.width E.fill ] E.none - - cards cardPage = - let - attrs = - if isMobile model.device then - [ E.width E.fill - , E.height <| E.fillPortion 3 - ] - - else - [ E.width <| E.fillPortion 2 - , E.height E.fill - ] - in - E.column attrs - [ E.row - [ E.spacing 5 - , E.padding 5 - , E.width E.fill - ] - [ navButton "<-" cardPage.prev - , navButton "->" cardPage.next - ] - , E.column - [ E.width E.fill - , E.height E.fill - , E.scrollbarY - ] - <| - List.map (cardRow model.activeCard) cardPage.values - ] - in - case model.cardPage of - Failed -> - E.none - - Loading cardPage -> - E.el - [ E.height E.fill - , E.centerX - ] - <| - E.html <| - Spinner.view manaSpinner model.spinner - - Ready cardPage -> - if isMobile model.device then - E.column - [ E.width E.fill - , E.height E.fill - , Font.color colors.text - , E.scrollbarY - ] - [ details, closedetails, cards cardPage ] - - else - E.row - [ E.width E.fill - , E.height E.fill - , Font.color colors.text - , E.scrollbarY - ] - [ details, cards cardPage ] - - -onEnter : msg -> E.Attribute msg -onEnter msg = - E.htmlAttribute - (Html.Events.on "keyup" - (Json.Decode.field "key" Json.Decode.string - |> Json.Decode.andThen - (\key -> - if key == "Enter" then - Json.Decode.succeed msg - - else - Json.Decode.fail "Not the enter key" - ) - ) - ) - - view : Model -> Browser.Document Msg view model = + let + viewPage page = + case page of + Collection pageModel -> + E.map CollectionMsg <| Collection.view pageModel + + _ -> + E.none + in { title = "Tutor" , body = [ E.layout - [ Background.color colors.background + [ Background.color UI.colors.background , E.height E.fill ] <| @@ -763,37 +186,28 @@ view model = [ E.width E.fill , E.height E.fill ] - [ searchBar model - , viewCardBrowser model - , E.el - [ E.height (E.px 50) - , E.width E.fill - , E.padding 10 - , Font.color colors.text - , Background.color colors.navBar - , E.alignBottom - ] - <| - case model.collectionStatistics of - Just statistics -> - E.el [ E.centerY, Font.size 16, Font.italic ] <| - E.text <| - String.concat - [ String.fromInt statistics.cards - , " cards in collection spanning " - , String.fromInt statistics.sets - , " sets (Estimated value: $" - , statistics.value - , ")" - ] - - Nothing -> - E.none + [ viewPage model.page ] ] } +subscriptions : Model -> Sub Msg +subscriptions model = + let + global = + [ Browser.Events.onResize + (\w h -> ViewportChanged { width = w, height = h }) + ] + in + case model.page of + Collection pageModel -> + Sub.batch (Sub.map CollectionMsg (Collection.subscriptions pageModel) :: global) + + _ -> + Sub.batch global + + main : Program Json.Decode.Value Model Msg main = Browser.application @@ -802,11 +216,5 @@ main = , onUrlRequest = LinkClicked , view = view , update = update - , subscriptions = - \_ -> - Sub.batch - [ Browser.Events.onResize - (\w h -> ViewportChanged { width = w, height = h }) - , Sub.map SpinnerMsg Spinner.subscription - ] + , subscriptions = subscriptions } diff --git a/www/src/Collection.elm b/www/src/Collection.elm index fa5648b..1eb1d2f 100644 --- a/www/src/Collection.elm +++ b/www/src/Collection.elm @@ -1,7 +1,31 @@ module Collection exposing (..) +import Browser +import Browser.Dom +import Browser.Events +import Browser.Navigation +import Card +import Color +import Dict +import Element as E +import Element.Background as Background +import Element.Border as Border +import Element.Events as Events +import Element.Font as Font +import Element.Input as Input +import Html.Events +import Http import Json.Decode import Json.Decode.Pipeline as JDP +import Maybe.Extra +import Paginated +import Spinner +import Task +import UI +import Url +import Url.Builder +import Url.Parser exposing ((), ()) +import Url.Parser.Query type alias Statistics = @@ -17,3 +41,655 @@ decodeStatistics = |> JDP.required "cards" Json.Decode.int |> JDP.required "sets" Json.Decode.int |> JDP.required "value" Json.Decode.string + + +type alias Criteria = + { query : String + , sortBy : String + , ownedOnly : Bool + } + + +type alias Model = + { navigationKey : Browser.Navigation.Key + , url : Url.Url + , device : E.Device + , spinner : Spinner.Model + , criteria : Criteria + , cardPage : CardPage + , activeCard : Maybe Card.Card + , collectionStatistics : Maybe Statistics + } + + +type Msg + = UrlChanged Url.Url + | ViewportChanged UI.Dimensions + | LinkClicked Browser.UrlRequest + | SpinnerMsg Spinner.Msg + | UpdateCriteria CriteriaMsg + | Search + | GetPage Url.Url + | GotStatistics (Result Http.Error Statistics) + | FoundCards (Result Http.Error (Paginated.Page Card.Card)) + | ShowCardDetails Card.Card + | ClearCardDetails + + +type CriteriaMsg + = UpdateName String + | UpdateSortBy String + | UpdateOwnedOnly Bool + + +type CardPage + = Ready (Paginated.Page Card.Card) + | Loading (Paginated.Page Card.Card) + | Failed + + +toLoading : CardPage -> CardPage +toLoading cardPage = + case cardPage of + Ready page -> + Loading page + + Loading page -> + Loading page + + Failed -> + Loading Paginated.empty + + +searchQuery : Criteria -> List Url.Builder.QueryParameter +searchQuery criteria = + [ Url.Builder.string "q" criteria.query + , Url.Builder.string "sort_by" criteria.sortBy + , Url.Builder.string "in_collection" + (if criteria.ownedOnly then + "yes" + + else + "no" + ) + ] + + +search : Criteria -> Cmd Msg +search criteria = + loadPage <| + Url.Builder.absolute + [ "api", "search" ] + (Url.Builder.int "limit" 18 :: searchQuery criteria) + + +loadPage : String -> Cmd Msg +loadPage url = + Http.get + { url = url + , expect = Paginated.expectJson FoundCards Card.decode + } + + +getCollectionStatistics : Cmd Msg +getCollectionStatistics = + Http.get + { url = Url.Builder.absolute [ "api", "collection" ] [] + , expect = Http.expectJson GotStatistics decodeStatistics + } + + +parseUrl : Url.Parser.Parser (Criteria -> a) a +parseUrl = + let + query = + Url.Parser.Query.string "q" + |> Url.Parser.Query.map (Maybe.withDefault "") + + sortBy = + Url.Parser.Query.enum "sort_by" + (Dict.fromList + [ ( "rarity", "rarity" ) + , ( "price", "price" ) + ] + ) + |> Url.Parser.Query.map (Maybe.withDefault "rarity") + + inCollection = + Url.Parser.Query.enum "in_collection" + (Dict.fromList + [ ( "true", True ) + , ( "false", False ) + , ( "yes", True ) + , ( "no", False ) + ] + ) + |> Url.Parser.Query.map (Maybe.withDefault True) + in + Url.Parser.top Url.Parser.Query.map3 Criteria query sortBy inCollection + + +criteriaFromUrl : Url.Url -> Criteria +criteriaFromUrl url = + let + emptyCriteria = + { query = "", sortBy = "price", ownedOnly = True } + in + Url.Parser.parse parseUrl url + |> Maybe.withDefault emptyCriteria + + +init : Browser.Navigation.Key -> Url.Url -> E.Device -> ( Model, Cmd Msg ) +init key url device = + let + criteria = + criteriaFromUrl url + in + ( { navigationKey = key + , url = url + , device = device + , spinner = Spinner.init + , criteria = criteria + , cardPage = Loading Paginated.empty + , activeCard = Nothing + , collectionStatistics = Nothing + } + , Cmd.batch + [ UI.getViewport ViewportChanged + , search criteria + , getCollectionStatistics + ] + ) + + +updateCriteria : CriteriaMsg -> Criteria -> Criteria +updateCriteria msg model = + case msg of + UpdateName text -> + { model | query = text } + + UpdateSortBy column -> + { model | sortBy = column } + + UpdateOwnedOnly value -> + { model | ownedOnly = value } + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + UrlChanged url -> + let + criteria = + Debug.log "criteria" <| criteriaFromUrl url + in + ( { model | criteria = criteria }, search criteria ) + + LinkClicked _ -> + ( model, Cmd.none ) + + ViewportChanged viewport -> + ( { model + | device = E.classifyDevice viewport + } + , Cmd.none + ) + + SpinnerMsg msg_ -> + ( { model | spinner = Spinner.update msg_ model.spinner }, Cmd.none ) + + UpdateCriteria msg_ -> + let + newCriteria = + updateCriteria msg_ model.criteria + in + case msg_ of + UpdateName _ -> + ( { model | criteria = newCriteria } + , Cmd.none + ) + + UpdateSortBy _ -> + update Search { model | criteria = newCriteria } + + UpdateOwnedOnly _ -> + update Search { model | criteria = newCriteria } + + Search -> + ( { model + | cardPage = toLoading model.cardPage + , activeCard = Nothing + } + , Cmd.batch + [ Browser.Navigation.pushUrl model.navigationKey <| + Url.Builder.relative [] (searchQuery model.criteria) + ] + ) + + GetPage url -> + ( { model | cardPage = toLoading model.cardPage }, loadPage (Url.toString url) ) + + GotStatistics (Ok statistics) -> + ( { model | collectionStatistics = Just statistics }, Cmd.none ) + + GotStatistics (Err _) -> + ( model, Cmd.none ) + + FoundCards (Ok cardPage) -> + ( { model | cardPage = Ready cardPage }, Cmd.none ) + + FoundCards (Err _) -> + ( model, Cmd.none ) + + ShowCardDetails card -> + ( { model | activeCard = Just card }, Cmd.none ) + + ClearCardDetails -> + ( { model | activeCard = Nothing }, Cmd.none ) + + +searchBar : Model -> E.Element Msg +searchBar model = + let + alignment = + if UI.isMobile model.device then + E.column + + else + E.row + in + alignment + [ E.padding 10 + , E.spacing 10 + , E.width E.fill + , Background.color UI.colors.navBar + ] + [ E.row [ E.spacing 10, E.width E.fill ] + [ Input.text + [ onEnter Search + , Background.color UI.colors.background + , Font.color UI.colors.text + , E.width (E.fill |> E.minimum 150) + ] + { onChange = UpdateCriteria << UpdateName + , text = model.criteria.query + , placeholder = Nothing + , label = Input.labelHidden "Search Input" + } + , Input.button + [ Background.color UI.colors.primary + , Font.color UI.colors.text + , Border.rounded 10 + , E.padding 10 + ] + { onPress = Just Search + , label = E.text "Search" + } + ] + , E.row [ E.spacing 10 ] + [ Input.radio [ E.padding 10 ] + { onChange = UpdateCriteria << UpdateSortBy + , selected = Just model.criteria.sortBy + , label = Input.labelLeft [ Font.color UI.colors.text ] (E.text "Sort by") + , options = + [ Input.option "price" <| E.el [ Font.color UI.colors.text ] <| E.text "Price DESC" + , Input.option "rarity" <| E.el [ Font.color UI.colors.text ] <| E.text "Rarity DESC" + ] + } + ] + ] + + +viewCardBrowser : Model -> E.Element Msg +viewCardBrowser model = + let + viewCard : UI.Dimensions -> Card.Card -> E.Element Msg + viewCard dimensions cardModel = + E.el + [ Border.rounded 10 + , E.clip + , E.width <| E.px dimensions.width + , E.height <| E.px dimensions.height + ] + <| + E.image + [ E.width <| E.px dimensions.width + , E.height <| E.px dimensions.height + , E.behindContent <| + E.html <| + Spinner.view UI.manaSpinner model.spinner + ] + { src = + Url.Builder.crossOrigin "https://api.scryfall.com" + [ "cards", cardModel.scryfallId ] + [ Url.Builder.string "format" "image" + , Url.Builder.string "version" "border_crop" + ] + , description = cardModel.name + } + + badge color foil text = + E.el + [ Border.rounded 5 + , Border.color color + , Border.width 1 + , E.width <| E.px 60 + , Font.family [ Font.typeface "sans" ] + , Font.size 10 + , Font.color UI.colors.title + ] + <| + E.row [ E.height E.fill, E.width E.fill ] + [ E.el [ E.padding 2, E.width E.fill ] <| E.text text + , E.row [ E.padding 1, E.height E.fill, E.width E.fill, Background.color color ] + [ if foil then + E.el + [ E.width E.fill + , E.height E.fill + , Background.gradient + { angle = 4.0 + , steps = + [ E.rgb 148 0 211 + , E.rgb 75 0 130 + , E.rgb 0 0 255 + , E.rgb 0 255 0 + , E.rgb 255 255 0 + , E.rgb 255 127 0 + , E.rgb 255 0 0 + ] + } + ] + E.none + + else + E.none + ] + ] + + setBadge : Card.Card -> E.Element Msg + setBadge card = + let + color = + case card.rarity of + "mythic" -> + UI.colors.mythic + + "rare" -> + UI.colors.rare + + "uncommon" -> + UI.colors.uncommon + + _ -> + UI.colors.common + in + badge color card.foil card.setCode + + priceBadge { currency, amount } = + E.el + [ Border.rounded 5 + , Border.color UI.colors.text + , E.width <| E.px 60 + , E.padding 2 + , Font.family [ Font.typeface "sans" ] + , Font.size 10 + ] + <| + E.row [ E.width E.fill ] + [ E.el [ E.width <| E.fillPortion 1 ] <| E.text <| String.toUpper currency + , E.el [ E.width <| E.fillPortion 2, Font.alignRight ] <| E.text amount + ] + + prices card = + Maybe.Extra.values + [ Maybe.map (\usd -> { currency = "usd", amount = usd }) <| + Maybe.Extra.or card.prices.usd card.prices.usd_foil + , Maybe.map (\eur -> { currency = "eur", amount = eur }) <| + Maybe.Extra.or card.prices.eur card.prices.eur_foil + , Maybe.map (\tix -> { currency = "tix", amount = tix }) card.prices.tix + ] + + cardDetails card = + E.column + [ E.spacing 20 + , E.padding 10 + ] + <| + E.el [ E.centerX ] + (viewCard { width = 192, height = 272 } card) + :: (E.row [ E.spacing 5, E.centerX ] <| List.map priceBadge (prices card)) + :: E.paragraph [ Font.heavy, Font.size 24, Font.center, Font.color UI.colors.title ] [ E.text card.name ] + :: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ]) + (String.lines card.oracleText) + + cardRow : Maybe Card.Card -> Card.Card -> E.Element Msg + cardRow activeCard cardModel = + let + interactiveAttributes = + if activeCard == Just cardModel then + [ Background.color UI.colors.selected ] + + else + [ E.pointer + , E.mouseOver [ Background.color UI.colors.hover ] + , Events.onClick <| ShowCardDetails cardModel + ] + in + E.row + ([ E.width E.fill + , E.spacing 10 + , E.padding 3 + ] + ++ interactiveAttributes + ) + [ E.el [ E.width <| E.px 100 ] <| + E.image + [ E.height <| E.px 60 + , E.centerX + ] + { src = + Url.Builder.crossOrigin "https://api.scryfall.com" + [ "cards", cardModel.scryfallId ] + [ Url.Builder.string "format" "image" + , Url.Builder.string "version" "art_crop" + ] + , description = cardModel.name + } + , E.column [ E.centerY, E.height E.fill, E.width E.fill, E.clipX ] + [ E.el [ Font.color UI.colors.title ] <| E.text cardModel.name + , E.el [ Font.size 16, Font.italic, Font.color UI.colors.subtitle ] <| E.text cardModel.collection + ] + , E.column [ E.alignRight, E.height E.fill ] <| + setBadge cardModel + :: List.map priceBadge (prices cardModel) + ] + + details = + if UI.isMobile model.device then + case model.activeCard of + Just card -> + E.column + [ E.spacing 10 + , E.padding 10 + , E.height <| E.fillPortion 1 + , E.width E.fill + , Background.color UI.colors.sidebar + , E.scrollbarY + ] + <| + E.paragraph [ Font.heavy, Font.size 24, Font.center ] [ E.text card.name ] + :: List.map (\text -> E.paragraph [ Font.size 16 ] [ E.text text ]) + (String.lines card.oracleText) + + Nothing -> + E.none + + else + E.el + [ E.alignTop + , E.width <| E.fillPortion 1 + , E.height E.fill + , Background.color UI.colors.sidebar + ] + (Maybe.map + cardDetails + model.activeCard + |> Maybe.withDefault + E.none + ) + + closedetails = + case model.activeCard of + Just _ -> + Input.button + [ E.height (E.px 30) + , E.width E.fill + , Background.color UI.colors.secondary + , Border.rounded 5 + , Font.color UI.colors.text + , Font.center + ] + { label = E.text "Close", onPress = Just ClearCardDetails } + + Nothing -> + E.none + + navButton text maybeUrl = + case maybeUrl of + Just url -> + Input.button + [ E.height (E.px 30) + , E.width E.fill + , Background.color UI.colors.primary + , Border.rounded 5 + , Font.color UI.colors.text + , Font.center + ] + { label = E.text text, onPress = Just (GetPage url) } + + Nothing -> + E.el [ E.width E.fill ] E.none + + cards cardPage = + let + attrs = + if UI.isMobile model.device then + [ E.width E.fill + , E.height <| E.fillPortion 3 + ] + + else + [ E.width <| E.fillPortion 2 + , E.height E.fill + ] + in + E.column attrs + [ E.row + [ E.spacing 5 + , E.padding 5 + , E.width E.fill + ] + [ navButton "<-" cardPage.prev + , navButton "->" cardPage.next + ] + , E.column + [ E.width E.fill + , E.height E.fill + , E.scrollbarY + ] + <| + List.map (cardRow model.activeCard) cardPage.values + ] + in + case model.cardPage of + Failed -> + E.none + + Loading cardPage -> + E.el + [ E.height E.fill + , E.centerX + ] + <| + E.html <| + Spinner.view UI.manaSpinner model.spinner + + Ready cardPage -> + if UI.isMobile model.device then + E.column + [ E.width E.fill + , E.height E.fill + , Font.color UI.colors.text + , E.scrollbarY + ] + [ details, closedetails, cards cardPage ] + + else + E.row + [ E.width E.fill + , E.height E.fill + , Font.color UI.colors.text + , E.scrollbarY + ] + [ details, cards cardPage ] + + +onEnter : msg -> E.Attribute msg +onEnter msg = + E.htmlAttribute + (Html.Events.on "keyup" + (Json.Decode.field "key" Json.Decode.string + |> Json.Decode.andThen + (\key -> + if key == "Enter" then + Json.Decode.succeed msg + + else + Json.Decode.fail "Not the enter key" + ) + ) + ) + + +view : Model -> E.Element Msg +view model = + E.column + [ E.width E.fill + , E.height E.fill + ] + [ searchBar model + , viewCardBrowser model + , E.el + [ E.height (E.px 50) + , E.width E.fill + , E.padding 10 + , Font.color UI.colors.text + , Background.color UI.colors.navBar + , E.alignBottom + ] + <| + case model.collectionStatistics of + Just statistics -> + E.el [ E.centerY, Font.size 16, Font.italic ] <| + E.text <| + String.concat + [ String.fromInt statistics.cards + , " cards in collection spanning " + , String.fromInt statistics.sets + , " sets (Estimated value: $" + , statistics.value + , ")" + ] + + Nothing -> + E.none + ] + + +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.batch + [ Browser.Events.onResize + (\w h -> ViewportChanged { width = w, height = h }) + , Sub.map SpinnerMsg Spinner.subscription + ] diff --git a/www/src/UI.elm b/www/src/UI.elm new file mode 100644 index 0000000..0b200ee --- /dev/null +++ b/www/src/UI.elm @@ -0,0 +1,129 @@ +module UI exposing + ( Dimensions + , colors + , getViewport + , isMobile + , manaSpinner + ) + +import Browser.Dom +import Color +import Element as E +import Spinner +import Task + + +type alias Dimensions = + { width : Int + , height : Int + } + + +colors = + let + blue = + E.rgb255 100 100 255 + + slate = + E.rgb255 150 150 200 + + lighterGrey = + E.rgb255 60 60 60 + + lightGrey = + E.rgb255 50 50 50 + + grey = + E.rgb255 40 40 40 + + darkGrey = + E.rgb255 30 30 30 + + darkerGrey = + E.rgb255 20 20 20 + + white = + E.rgb255 255 255 255 + + offwhite = + E.rgb255 200 200 200 + + mythic = + E.rgb255 205 55 0 + + rare = + E.rgb255 218 165 32 + + uncommon = + E.rgb255 112 128 144 + + common = + E.rgb255 47 79 79 + in + { primary = blue + , secondary = slate + , background = lightGrey + , navBar = darkerGrey + , sidebar = lighterGrey + , selected = darkGrey + , hover = grey + , title = white + , subtitle = offwhite + , text = offwhite + , mythic = mythic + , rare = rare + , uncommon = uncommon + , common = common + } + + +getViewport : (Dimensions -> msg) -> Cmd msg +getViewport msg = + Task.perform + (\x -> + msg + { width = floor x.viewport.width + , height = floor x.viewport.height + } + ) + Browser.Dom.getViewport + + +isMobile : E.Device -> Bool +isMobile device = + case device.orientation of + E.Landscape -> + False + + E.Portrait -> + True + + +manaSpinner : Spinner.Config +manaSpinner = + let + color index = + if index < 1.0 then + Color.red + + else if index < 2.0 then + Color.green + + else if index < 3.0 then + Color.purple + + else if index < 4.0 then + Color.blue + + else + Color.white + + default = + Spinner.defaultConfig + in + { default + | lines = 5.0 + , length = 0.0 + , width = 20 + , color = color + }