WIP: Add decks

This commit is contained in:
Correl Roush 2023-01-09 15:00:37 -05:00
parent 1506960047
commit f873ba7967
11 changed files with 414 additions and 55 deletions

View file

@ -107,7 +107,7 @@ CREATE TABLE IF NOT EXISTS "decks" (
"name" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "deck_cards" (
CREATE TABLE IF NOT EXISTS "deck_list" (
"deck_id" INTEGER NOT NULL,
"oracle_id" UUID NOT NULL,
"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
);
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" (
"key" TEXT PRIMARY KEY,
"value" TEXT

View file

@ -62,6 +62,19 @@ class Legality(enum.Enum):
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
class Card:
scryfall_id: uuid.UUID
@ -92,3 +105,15 @@ class CardCopy:
language: str = "English"
collection: str = "Default"
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.resources
import json
import re
import typing
import urllib.parse
@ -10,6 +11,8 @@ import psycopg.rows
import psycopg_pool
import tornado.ioloop
import tornado.web
import tornado_openapi3.handler
import yaml
import tutor.database
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):
def url(self, url: str) -> str:
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):
def initialize(
self,
@ -151,6 +185,14 @@ class TemplateHandler(tornado.web.RequestHandler):
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):
def __init__(self, **settings):
version = importlib.metadata.version(__package__)
@ -174,13 +216,14 @@ class Application(tornado.web.Application):
),
(r"/api/search", SearchHandler),
(r"/api/collection", CollectionHandler),
(r"/api/decks", DecksHandler),
]
if static_path := settings.get("static"):
paths.extend(
[
(
r"/(.*)",
tornado.web.StaticFileHandler,
fr"/(.*)",
StaticFileHandler,
{"path": static_path, "default_filename": "index.html"},
),
]

View file

@ -32,3 +32,140 @@ paths:
application/x-yaml:
schema:
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>
<title>Tutor</title>
<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.gstatic.com" crossorigin>
<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.Navigation
import Card
import Collection
import Color
import Dict
import Element as E
@ -18,7 +17,10 @@ import Html.Events
import Http
import Json.Decode
import Maybe.Extra
import Pages.Collection
import Pages.DeckList
import Paginated
import Route
import Spinner
import Task
import UI
@ -33,7 +35,7 @@ type alias Model =
, url : Url.Url
, viewport : UI.Dimensions
, device : E.Device
, route : Route
, route : Maybe Route.Route
, page : Page
}
@ -42,33 +44,15 @@ type Msg
= UrlChanged Url.Url
| ViewportChanged UI.Dimensions
| LinkClicked Browser.UrlRequest
| CollectionMsg Collection.Msg
| CollectionMsg Pages.Collection.Msg
| DeckListMsg Pages.DeckList.Msg
| SpinnerMsg Spinner.Msg
type Page
= NotFound
| Collection Collection.Model
| Decks
type Route
= Home
| MyCollection
| MyDecks
routeToUrl : Route -> String
routeToUrl route =
case route of
Home ->
Url.Builder.absolute [] []
MyCollection ->
Url.Builder.absolute [ "collection" ] []
MyDecks ->
Url.Builder.absolute [ "decks" ] []
| Collection Pages.Collection.Model
| DeckList Pages.DeckList.Model
init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg )
@ -80,19 +64,36 @@ init _ url key =
device =
E.classifyDevice viewport
( pageModel, pageCmd ) =
Collection.init key url device
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
( { navigationKey = key
, url = url
, viewport = viewport
, device = device
, route = MyCollection
, page = Collection pageModel
, route = route
, page = page
}
, Cmd.batch
[ UI.getViewport ViewportChanged
, Cmd.map CollectionMsg pageCmd
, pageCmd
]
)
@ -105,15 +106,40 @@ updateWith pageType pageMsg model ( subModel, subCmd ) =
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case ( msg, model.page ) of
( UrlChanged url, Collection pageModel ) ->
Collection.update (Collection.UrlChanged url) pageModel
|> updateWith Collection CollectionMsg model
( UrlChanged url, _ ) ->
( model, Cmd.none )
let
oldRoute =
model.route
( LinkClicked _, _ ) ->
( model, Cmd.none )
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
( { newModel | route = Route.fromUrl url }, cmds )
( LinkClicked urlRequest, _ ) ->
case urlRequest of
Browser.Internal url ->
( model, Browser.Navigation.pushUrl model.navigationKey (Url.toString url) )
Browser.External url ->
( model, Browser.Navigation.load url )
( ViewportChanged viewport, _ ) ->
( { model
@ -124,11 +150,11 @@ update msg model =
)
( SpinnerMsg spinnerMsg, Collection pageModel ) ->
Collection.update (Collection.SpinnerMsg spinnerMsg) pageModel
Pages.Collection.update (Pages.Collection.SpinnerMsg spinnerMsg) pageModel
|> updateWith Collection CollectionMsg model
( CollectionMsg pageMsg, Collection pageModel ) ->
Collection.update pageMsg pageModel
Pages.Collection.update pageMsg pageModel
|> updateWith Collection CollectionMsg model
( _, _ ) ->
@ -138,15 +164,21 @@ update msg model =
navBar : Model -> E.Element Msg
navBar model =
let
navLink : Route -> String -> E.Element Msg
navLink : Route.Route -> String -> E.Element Msg
navLink route text =
E.link
[ E.pointer
, E.padding 10
, Font.center
, Background.color UI.colors.primary
, Background.color <|
if Just route == model.route then
UI.colors.primary
else
UI.colors.background
, E.mouseOver [ Background.color UI.colors.primary ]
]
{ url = routeToUrl route, label = E.text text }
{ url = Route.toUrl route, label = E.text text }
in
E.row
[ E.padding 10
@ -156,10 +188,9 @@ navBar model =
, Font.color UI.colors.text
]
[ E.el [ E.width <| E.fillPortion 1 ] <| E.text "Tutor"
, E.row [ E.width <| E.fillPortion 4 ]
[ navLink MyCollection "Collection"
-- , navLink "" "Decks"
, E.row [ E.width <| E.fillPortion 4, E.spacing 3 ]
[ navLink Route.Collection "Collection"
, navLink Route.DeckList "Decks"
]
]
@ -170,10 +201,32 @@ view model =
viewPage page =
case page of
Collection pageModel ->
E.map CollectionMsg <| Collection.view pageModel
E.map CollectionMsg <| Pages.Collection.view pageModel
_ ->
E.none
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"
, body =
@ -186,7 +239,8 @@ view model =
[ E.width E.fill
, E.height E.fill
]
[ viewPage model.page
[ navBar model
, viewPage model.page
]
]
}
@ -202,7 +256,7 @@ subscriptions model =
in
case model.page of
Collection pageModel ->
Sub.batch (Sub.map CollectionMsg (Collection.subscriptions pageModel) :: global)
Sub.batch (Sub.map CollectionMsg (Pages.Collection.subscriptions pageModel) :: global)
_ ->
Sub.batch global

View file

@ -1,4 +1,4 @@
module Collection exposing (..)
module Pages.Collection exposing (..)
import Browser
import Browser.Dom

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

View file

@ -4,11 +4,14 @@ module UI exposing
, getViewport
, isMobile
, manaSpinner
, text
, title
)
import Browser.Dom
import Color
import Element as E
import Element.Font as Font
import Spinner
import Task
@ -127,3 +130,13 @@ manaSpinner =
, 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