Compare commits

...

4 commits

13 changed files with 1297 additions and 731 deletions

View file

@ -35,7 +35,7 @@ Matches cards of the chosen color or colors.
(blue/red), =gruul= (red/green), =azorius= (white/blue), =rakdos= (black/red), (blue/red), =gruul= (red/green), =azorius= (white/blue), =rakdos= (black/red),
=simic= (green/blue) =simic= (green/blue)
- Alaran shards :: =bant= (white/green/blue), =esper= (blue/white/black), - Alaran shards :: =bant= (white/green/blue), =esper= (blue/white/black),
=grixis= (black/blue/red), =jund= (red/blue/green), =naya= (green/red/white) =grixis= (black/blue/red), =jund= (red/black/green), =naya= (green/red/white)
- Tarkirian wedges :: =abzan= (white/black/green), =jeskai= (white/blue/red), - Tarkirian wedges :: =abzan= (white/black/green), =jeskai= (white/blue/red),
=sultai= (blue/black/green), =mardu= (white/black/red), =temur= =sultai= (blue/black/green), =mardu= (white/black/red), =temur=
(blue/red/green) (blue/red/green)

View file

@ -107,7 +107,7 @@ CREATE TABLE IF NOT EXISTS "decks" (
"name" TEXT NOT NULL "name" TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS "deck_cards" ( CREATE TABLE IF NOT EXISTS "deck_list" (
"deck_id" INTEGER NOT NULL, "deck_id" INTEGER NOT NULL,
"oracle_id" UUID NOT NULL, "oracle_id" UUID NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1, "quantity" INTEGER NOT NULL DEFAULT 1,
@ -115,6 +115,14 @@ CREATE TABLE IF NOT EXISTS "deck_cards" (
FOREIGN KEY ("deck_id") REFERENCES "decks" ("deck_id") ON DELETE CASCADE FOREIGN KEY ("deck_id") REFERENCES "decks" ("deck_id") ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS "deck_cards" (
"deck_id" INTEGER NOT NULL,
"scryfall_id" UUID NOT NULL,
PRIMARY KEY ("deck_id", "scryfall_id"),
FOREIGN KEY ("deck_id") REFERENCES "decks" ("deck_id") ON DELETE CASCADE,
FOREIGN KEY ("scryfall_id") REFERENCES "cards" ("scryfall_id")
);
CREATE TABLE IF NOT EXISTS "vars" ( CREATE TABLE IF NOT EXISTS "vars" (
"key" TEXT PRIMARY KEY, "key" TEXT PRIMARY KEY,
"value" TEXT "value" TEXT

View file

@ -62,6 +62,19 @@ class Legality(enum.Enum):
Banned = "banned" Banned = "banned"
@dataclasses.dataclass
class OracleCard:
oracle_id: uuid.UUID
name: str
color_identity: typing.List[Color]
cmc: decimal.Decimal
type_line: str
games: typing.Set[Game]
legalities: typing.Dict[str, Legality]
edhrec_rank: typing.Optional[int] = None
oracle_text: typing.Optional[str] = None
@dataclasses.dataclass @dataclasses.dataclass
class Card: class Card:
scryfall_id: uuid.UUID scryfall_id: uuid.UUID
@ -92,3 +105,15 @@ class CardCopy:
language: str = "English" language: str = "English"
collection: str = "Default" collection: str = "Default"
condition: typing.Optional[str] = None condition: typing.Optional[str] = None
@dataclasses.dataclass
class DeckCard:
card: OracleCard
quantity: int = 1
@dataclasses.dataclass
class Deck:
name: str
cards: typing.List[DeckCard]

View file

@ -2,6 +2,7 @@ import decimal
import importlib.metadata import importlib.metadata
import importlib.resources import importlib.resources
import json import json
import re
import typing import typing
import urllib.parse import urllib.parse
@ -10,6 +11,8 @@ import psycopg.rows
import psycopg_pool import psycopg_pool
import tornado.ioloop import tornado.ioloop
import tornado.web import tornado.web
import tornado_openapi3.handler
import yaml
import tutor.database import tutor.database
import tutor.models import tutor.models
@ -36,6 +39,25 @@ def update_args(url: str, **qargs) -> str:
) )
class OpenAPIRequestHandler(tornado_openapi3.handler.OpenAPIRequestHandler):
@property
def spec_dict(self):
spec = getattr(self.application, "openapi_spec_dict", None)
if not spec:
version = importlib.metadata.version(__package__)
spec = yaml.safe_load(self.render_string("openapi.yaml", version=version))
setattr(self.application, "openapi_spec_dict", spec)
return spec
@property
def spec(self):
spec = getattr(self.application, "openapi_spec", None)
if not spec:
spec = super().spec
setattr(self.application, "openapi_spec", spec)
return spec
class SearchHandler(tornado.web.RequestHandler): class SearchHandler(tornado.web.RequestHandler):
def url(self, url: str) -> str: def url(self, url: str) -> str:
scheme_override = self.application.settings["scheme"] scheme_override = self.application.settings["scheme"]
@ -135,6 +157,18 @@ class CollectionHandler(tornado.web.RequestHandler):
) )
class DecksHandler(tornado.web.RequestHandler):
async def get(self) -> None:
self.finish(
{
"decks": [],
}
)
async def post(self) -> None:
...
class TemplateHandler(tornado.web.RequestHandler): class TemplateHandler(tornado.web.RequestHandler):
def initialize( def initialize(
self, self,
@ -151,6 +185,14 @@ class TemplateHandler(tornado.web.RequestHandler):
return self.render(self.path, **self.vars) return self.render(self.path, **self.vars)
class StaticFileHandler(tornado.web.StaticFileHandler):
@classmethod
def get_absolute_path(cls, root: str, path: str) -> str:
# Rewrite paths to load the index
if path in ("collection", "decks", "decks/new123123"):
path = "index.html"
return tornado.web.StaticFileHandler.get_absolute_path(root, path)
class Application(tornado.web.Application): class Application(tornado.web.Application):
def __init__(self, **settings): def __init__(self, **settings):
version = importlib.metadata.version(__package__) version = importlib.metadata.version(__package__)
@ -174,13 +216,14 @@ class Application(tornado.web.Application):
), ),
(r"/api/search", SearchHandler), (r"/api/search", SearchHandler),
(r"/api/collection", CollectionHandler), (r"/api/collection", CollectionHandler),
(r"/api/decks", DecksHandler),
] ]
if static_path := settings.get("static"): if static_path := settings.get("static"):
paths.extend( paths.extend(
[ [
( (
r"/(.*)", fr"/(.*)",
tornado.web.StaticFileHandler, StaticFileHandler,
{"path": static_path, "default_filename": "index.html"}, {"path": static_path, "default_filename": "index.html"},
), ),
] ]

View file

@ -32,3 +32,140 @@ paths:
application/x-yaml: application/x-yaml:
schema: schema:
type: string type: string
/search:
get:
summary: Search the card database
tags:
- Collection
parameters:
- name: q
in: query
description: >-
Text in the query string will be used to filter cards having that text in their
name. Additionally, the keyword expressions below can be used to search for
cards with certain properties.
### Examples
`bolt`
: Find all cards with "bolt" in the name
`"God of"`
: Find all cards with "God of" in the name
`t:legendary t:creature c:jund`
: Find all legendary creatures with a color
identity of red/blue/green
`color<=ubg`
: Find all spells that are blue, black, green, or any
combination thereof.
`color:red set:stx rarity>=rare`
: Find all red cards in Strixhaven that are
rare or mythic
`t:enchantment o:"enters the battlefield"`
: Find all enchantments with ETB
effects
### Keywords
#### Colors
Keywords
: `c`, `color`
Operators
: `:` (matches), `>=` (greater than or equal to), `<=` (less than
or equal to)
Matches cards of the chosen color or colors.
Single colors
: `w` or `white`, `u` or `blue`, `b` or `black, =g` or `green`, `r` or `red`
Any combination of abbreviated single colors
: e.g.: `rg`, `uw`, or `wubgr`
Ravnican guilds
: `boros` (white/red), `golgari` (green/black), `selesnya`
(green/white), `dimir` (blue/black), `orzhov` (white/black), `izzet`
(blue/red), `gruul` (red/green), `azorius` (white/blue), `rakdos` (black/red),
`simic` (green/blue)
Alaran shards
: `bant` (white/green/blue), `esper` (blue/white/black),
`grixis` (black/blue/red), `jund` (red/black/green), `naya` (green/red/white)
Tarkirian wedges
: `abzan` (white/black/green), `jeskai` (white/blue/red),
`sultai` (blue/black/green), `mardu` (white/black/red), `temur`
(blue/red/green)
#### Sets
Keywords
: `s`, `set`, `e`, `expansion`
Operators
: `:` (matches)
#### Rarity
Keywords
: `r`, `rarity`
Operators
: `:` (matches), `>=` (greater than or equal to), `<=` (less than
or equal to)
#### Type
Keywords
: `t`, `type`
Operators
: `:` (matches)
#### Oracle Text
Keywords
: `o`, `oracle`
Operators
: `:` (matches)
responses:
'200':
description: Search results
/collection:
get:
summary: Collection statistics
tags:
- Collection
responses:
'200':
description: Collection statistics
content:
application/json:
schema:
$ref: '#/components/schemas/collection_statistics'
components:
schemas:
collection_statistics:
type: object
properties:
cards:
description: Total number of cards in the collection
type: integer
example: 765
value:
description: Total estimated value of the collection
type: number
format: float
example: 1234.56
sets:
description: Total number of sets in the collection
type: integer
example: 15

View file

@ -2,7 +2,7 @@
<head> <head>
<title>Tutor</title> <title>Tutor</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<script type="text/javascript" src="elm.js"></script> <script type="text/javascript" src="/elm.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville&display=swap" rel="stylesheet">

View file

@ -5,7 +5,6 @@ import Browser.Dom
import Browser.Events import Browser.Events
import Browser.Navigation import Browser.Navigation
import Card import Card
import Collection
import Color import Color
import Dict import Dict
import Element as E import Element as E
@ -18,247 +17,131 @@ import Html.Events
import Http import Http
import Json.Decode import Json.Decode
import Maybe.Extra import Maybe.Extra
import Pages.Collection
import Pages.DeckList
import Paginated import Paginated
import Route
import Spinner import Spinner
import Task import Task
import UI
import Url import Url
import Url.Builder import Url.Builder
import Url.Parser exposing ((</>), (<?>)) import Url.Parser exposing ((</>), (<?>))
import Url.Parser.Query import Url.Parser.Query
type alias Dimensions =
{ width : Int
, height : Int
}
type alias Criteria =
{ query : String
, sortBy : String
, ownedOnly : Bool
}
type alias Model = type alias Model =
{ navigationKey : Browser.Navigation.Key { navigationKey : Browser.Navigation.Key
, viewport : Dimensions , url : Url.Url
, viewport : UI.Dimensions
, device : E.Device , device : E.Device
, spinner : Spinner.Model , route : Maybe Route.Route
, criteria : Criteria , page : Page
, cardPage : CardPage
, activeCard : Maybe Card.Card
, collectionStatistics : Maybe Collection.Statistics
} }
type Msg type Msg
= UrlChanged Url.Url = UrlChanged Url.Url
| ViewportChanged Dimensions | ViewportChanged UI.Dimensions
| LinkClicked Browser.UrlRequest | LinkClicked Browser.UrlRequest
| CollectionMsg Pages.Collection.Msg
| DeckListMsg Pages.DeckList.Msg
| SpinnerMsg Spinner.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 type Page
= UpdateName String = NotFound
| UpdateSortBy String | Collection Pages.Collection.Model
| UpdateOwnedOnly Bool | DeckList Pages.DeckList.Model
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
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 = "rarity", ownedOnly = True }
in
Url.Parser.parse parseUrl url
|> Maybe.withDefault emptyCriteria
init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg )
init _ url key = init _ url key =
let let
criteria =
criteriaFromUrl url
viewport = viewport =
{ width = 1280, height = 720 } { width = 1280, height = 720 }
device =
E.classifyDevice viewport
route =
Route.fromUrl url
initWith : (pageModel -> Page) -> (pageMsg -> Msg) -> ( pageModel, Cmd pageMsg ) -> ( Page, Cmd Msg )
initWith pageType pageMsg ( subModel, subCmd ) =
( pageType subModel, Cmd.map pageMsg subCmd )
( page, pageCmd ) =
case route of
Just Route.Collection ->
initWith Collection CollectionMsg <|
Pages.Collection.init key url device
Just Route.DeckList ->
initWith DeckList DeckListMsg <|
Pages.DeckList.init key url device
_ ->
( NotFound, Cmd.none )
in in
( { navigationKey = key ( { navigationKey = key
, url = url
, viewport = viewport , viewport = viewport
, device = E.classifyDevice viewport , device = device
, spinner = Spinner.init , route = route
, criteria = criteria , page = page
, cardPage = Loading Paginated.empty
, activeCard = Nothing
, collectionStatistics = Nothing
} }
, Cmd.batch , Cmd.batch
[ search criteria [ UI.getViewport ViewportChanged
, getCollectionStatistics , pageCmd
, Task.perform
(\x ->
ViewportChanged
{ width = floor x.viewport.width
, height = floor x.viewport.height
}
)
Browser.Dom.getViewport
] ]
) )
updateCriteria : CriteriaMsg -> Criteria -> Criteria updateWith : (pageModel -> Page) -> (pageMsg -> Msg) -> Model -> ( pageModel, Cmd pageMsg ) -> ( Model, Cmd Msg )
updateCriteria msg model = updateWith pageType pageMsg model ( subModel, subCmd ) =
case msg of ( { model | page = pageType subModel }, Cmd.map pageMsg subCmd )
UpdateName text ->
{ model | query = text }
UpdateSortBy column ->
{ model | sortBy = column }
UpdateOwnedOnly value ->
{ model | ownedOnly = value }
update : Msg -> Model -> ( Model, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = update msg model =
case msg of case ( msg, model.page ) of
UrlChanged url -> ( UrlChanged url, _ ) ->
let let
criteria = oldRoute =
Debug.log "criteria" <| criteriaFromUrl url model.route
newRoute =
Route.fromUrl url
( newModel, cmds ) =
case ( model.page, newRoute ) of
( Collection pageModel, Just Route.Collection ) ->
Pages.Collection.update (Pages.Collection.UrlChanged url) pageModel
|> updateWith Collection CollectionMsg model
( _, Just Route.Collection ) ->
Pages.Collection.init model.navigationKey url model.device
|> updateWith Collection CollectionMsg model
( _, Just Route.DeckList ) ->
Pages.DeckList.init model.navigationKey url model.device
|> updateWith DeckList DeckListMsg model
_ ->
( { model | page = NotFound }, Cmd.none )
in in
( { model | criteria = criteria }, search criteria ) ( { newModel | route = Route.fromUrl url }, cmds )
LinkClicked _ -> ( LinkClicked urlRequest, _ ) ->
( model, Cmd.none ) case urlRequest of
Browser.Internal url ->
( model, Browser.Navigation.pushUrl model.navigationKey (Url.toString url) )
ViewportChanged viewport -> Browser.External url ->
( model, Browser.Navigation.load url )
( ViewportChanged viewport, _ ) ->
( { model ( { model
| viewport = viewport | viewport = viewport
, device = E.classifyDevice viewport , device = E.classifyDevice viewport
@ -266,502 +149,89 @@ update msg model =
, Cmd.none , Cmd.none
) )
SpinnerMsg msg_ -> ( SpinnerMsg spinnerMsg, Collection pageModel ) ->
( { model | spinner = Spinner.update msg_ model.spinner }, Cmd.none ) Pages.Collection.update (Pages.Collection.SpinnerMsg spinnerMsg) pageModel
|> updateWith Collection CollectionMsg model
UpdateCriteria msg_ -> ( CollectionMsg pageMsg, Collection pageModel ) ->
let Pages.Collection.update pageMsg pageModel
newCriteria = |> updateWith Collection CollectionMsg model
updateCriteria msg_ model.criteria
in
case msg_ of
UpdateName _ ->
( { model | criteria = newCriteria }
, Cmd.none
)
UpdateSortBy _ -> ( _, _ ) ->
update Search { model | criteria = newCriteria } ( model, Cmd.none )
UpdateOwnedOnly _ ->
update Search { model | criteria = newCriteria }
Search -> navBar : Model -> E.Element Msg
( { model navBar model =
| cardPage = toLoading model.cardPage let
, activeCard = Nothing navLink : Route.Route -> String -> E.Element Msg
} navLink route text =
, Cmd.batch E.link
[ Browser.Navigation.pushUrl model.navigationKey <| [ E.pointer
Url.Builder.relative [] (searchQuery model.criteria) , E.padding 10
, Font.center
, Background.color <|
if Just route == model.route then
UI.colors.primary
else
UI.colors.background
, E.mouseOver [ Background.color UI.colors.primary ]
] ]
) { url = Route.toUrl route, label = E.text text }
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
in in
{ primary = blue E.row
, 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.padding 10 [ E.padding 10
, E.spacing 10 , E.spacing 10
, E.width E.fill , 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 ] [ E.el [ E.width <| E.fillPortion 1 ] <| E.text "Tutor"
[ Input.text , E.row [ E.width <| E.fillPortion 4, E.spacing 3 ]
[ onEnter Search [ navLink Route.Collection "Collection"
, Background.color colors.background , navLink Route.DeckList "Decks"
, 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 "rarity" <| E.el [ Font.color colors.text ] <| E.text "Rarity DESC"
, Input.option "price" <| E.el [ Font.color colors.text ] <| E.text "Price DESC"
]
}
, Input.checkbox []
{ onChange = UpdateCriteria << UpdateOwnedOnly
, icon = Input.defaultCheckbox
, checked = model.criteria.ownedOnly
, label = Input.labelRight [ Font.color colors.text ] (E.text "Owned only?")
}
] ]
] ]
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 -> Browser.Document Msg
view model = view model =
let
viewPage page =
case page of
Collection pageModel ->
E.map CollectionMsg <| Pages.Collection.view pageModel
DeckList pageModel ->
E.map DeckListMsg <| Pages.DeckList.view pageModel
NotFound ->
E.column
[ E.width E.fill
, E.height E.fill
, E.padding 20
, E.spacing 20
]
[ E.el
[ Font.color UI.colors.title
, Font.size 60
, E.centerX
]
<|
E.text "404 Not Found"
, E.el
[ Font.color UI.colors.text
, E.centerX
]
<|
E.text "The page you requested could not be found."
]
in
{ title = "Tutor" { title = "Tutor"
, body = , body =
[ E.layout [ E.layout
[ Background.color colors.background [ Background.color UI.colors.background
, E.height E.fill , E.height E.fill
] ]
<| <|
@ -769,37 +239,29 @@ view model =
[ E.width E.fill [ E.width E.fill
, E.height E.fill , E.height E.fill
] ]
[ searchBar model [ navBar model
, viewCardBrowser model , viewPage model.page
, 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
] ]
] ]
} }
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 (Pages.Collection.subscriptions pageModel) :: global)
_ ->
Sub.batch global
main : Program Json.Decode.Value Model Msg main : Program Json.Decode.Value Model Msg
main = main =
Browser.application Browser.application
@ -808,11 +270,5 @@ main =
, onUrlRequest = LinkClicked , onUrlRequest = LinkClicked
, view = view , view = view
, update = update , update = update
, subscriptions = , subscriptions = subscriptions
\_ ->
Sub.batch
[ Browser.Events.onResize
(\w h -> ViewportChanged { width = w, height = h })
, Sub.map SpinnerMsg Spinner.subscription
]
} }

View file

@ -1,19 +0,0 @@
module Collection exposing (..)
import Json.Decode
import Json.Decode.Pipeline as JDP
type alias Statistics =
{ cards : Int
, sets : Int
, value : String
}
decodeStatistics : Json.Decode.Decoder Statistics
decodeStatistics =
Json.Decode.succeed Statistics
|> JDP.required "cards" Json.Decode.int
|> JDP.required "sets" Json.Decode.int
|> JDP.required "value" Json.Decode.string

View file

@ -0,0 +1,695 @@
module Pages.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 =
{ cards : Int
, sets : Int
, value : String
}
decodeStatistics : Json.Decode.Decoder Statistics
decodeStatistics =
Json.Decode.succeed Statistics
|> 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
]

View file

@ -0,0 +1,8 @@
module DeckEditor exposing (..)
import Card
type alias Deck =
{ cards : List Card.Card
}

View file

@ -0,0 +1,33 @@
module Pages.DeckList exposing (..)
import Browser.Navigation
import Element as E
import Element.Font as Font
import UI
import Url
type alias Model =
{ navigationKey : Browser.Navigation.Key
, url : Url.Url
, device : E.Device
}
type Msg
= None
init : Browser.Navigation.Key -> Url.Url -> E.Device -> ( Model, Cmd Msg )
init key url device =
( { navigationKey = key
, url = url
, device = device
}
, Cmd.none
)
view : Model -> E.Element Msg
view model =
E.column [ E.padding 40, E.centerX, Font.italic ] [ UI.text "No decks yet" ]

38
www/src/Route.elm Normal file
View file

@ -0,0 +1,38 @@
module Route exposing (Route(..), fromUrl, toUrl)
import Url exposing (Url)
import Url.Builder
import Url.Parser exposing (Parser, map, oneOf, parse, s, top)
type Route
= Home
| Collection
| DeckList
parser : Parser (Route -> a) a
parser =
oneOf
[ map Home top
, map Collection (s "collection")
, map DeckList (s "decks")
]
toUrl : Route -> String
toUrl route =
case route of
Home ->
Url.Builder.absolute [] []
Collection ->
Url.Builder.absolute [ "collection" ] []
DeckList ->
Url.Builder.absolute [ "decks" ] []
fromUrl : Url.Url -> Maybe Route
fromUrl url =
parse parser url

142
www/src/UI.elm Normal file
View file

@ -0,0 +1,142 @@
module UI exposing
( Dimensions
, colors
, getViewport
, isMobile
, manaSpinner
, text
, title
)
import Browser.Dom
import Color
import Element as E
import Element.Font as Font
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
}
text : String -> E.Element msg
text string =
E.el [ Font.color colors.text ] <| E.text string
title : String -> E.Element msg
title string =
E.el [ Font.color colors.title ] <| E.text string