Compare commits
4 commits
232accd01d
...
f873ba7967
Author | SHA1 | Date | |
---|---|---|---|
f873ba7967 | |||
1506960047 | |||
2e2f2be488 | |||
43bdae5156 |
13 changed files with 1297 additions and 731 deletions
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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"},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
864
www/src/App.elm
864
www/src/App.elm
|
@ -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 }
|
|
||||||
|
|
||||||
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 )
|
( model, Cmd.none )
|
||||||
|
|
||||||
FoundCards (Ok cardPage) ->
|
|
||||||
( { model | cardPage = Ready cardPage }, Cmd.none )
|
|
||||||
|
|
||||||
FoundCards (Err _) ->
|
navBar : Model -> E.Element Msg
|
||||||
( model, Cmd.none )
|
navBar model =
|
||||||
|
|
||||||
ShowCardDetails card ->
|
|
||||||
( { model | activeCard = Just card }, Cmd.none )
|
|
||||||
|
|
||||||
ClearCardDetails ->
|
|
||||||
( { model | activeCard = Nothing }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
colors =
|
|
||||||
let
|
let
|
||||||
blue =
|
navLink : Route.Route -> String -> E.Element Msg
|
||||||
E.rgb255 100 100 255
|
navLink route text =
|
||||||
|
E.link
|
||||||
slate =
|
[ E.pointer
|
||||||
E.rgb255 150 150 200
|
, E.padding 10
|
||||||
|
, Font.center
|
||||||
lighterGrey =
|
, Background.color <|
|
||||||
E.rgb255 60 60 60
|
if Just route == model.route then
|
||||||
|
UI.colors.primary
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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
|
else
|
||||||
E.row
|
UI.colors.background
|
||||||
|
, E.mouseOver [ Background.color UI.colors.primary ]
|
||||||
|
]
|
||||||
|
{ url = Route.toUrl route, label = E.text text }
|
||||||
in
|
in
|
||||||
alignment
|
E.row
|
||||||
[ 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
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
695
www/src/Pages/Collection.elm
Normal file
695
www/src/Pages/Collection.elm
Normal 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
|
||||||
|
]
|
8
www/src/Pages/DeckEditor.elm
Normal file
8
www/src/Pages/DeckEditor.elm
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
module DeckEditor exposing (..)
|
||||||
|
|
||||||
|
import Card
|
||||||
|
|
||||||
|
|
||||||
|
type alias Deck =
|
||||||
|
{ cards : List Card.Card
|
||||||
|
}
|
33
www/src/Pages/DeckList.elm
Normal file
33
www/src/Pages/DeckList.elm
Normal 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
38
www/src/Route.elm
Normal 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
142
www/src/UI.elm
Normal 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
|
Loading…
Reference in a new issue