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 "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,7 +17,10 @@ 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 UI
@ -33,7 +35,7 @@ type alias Model =
, url : Url.Url , url : Url.Url
, viewport : UI.Dimensions , viewport : UI.Dimensions
, device : E.Device , device : E.Device
, route : Route , route : Maybe Route.Route
, page : Page , page : Page
} }
@ -42,33 +44,15 @@ type Msg
= UrlChanged Url.Url = UrlChanged Url.Url
| ViewportChanged UI.Dimensions | ViewportChanged UI.Dimensions
| LinkClicked Browser.UrlRequest | LinkClicked Browser.UrlRequest
| CollectionMsg Collection.Msg | CollectionMsg Pages.Collection.Msg
| DeckListMsg Pages.DeckList.Msg
| SpinnerMsg Spinner.Msg | SpinnerMsg Spinner.Msg
type Page type Page
= NotFound = NotFound
| Collection Collection.Model | Collection Pages.Collection.Model
| Decks | DeckList Pages.DeckList.Model
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" ] []
init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg )
@ -80,19 +64,36 @@ init _ url key =
device = device =
E.classifyDevice viewport E.classifyDevice viewport
( pageModel, pageCmd ) = route =
Collection.init key url device 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 , url = url
, viewport = viewport , viewport = viewport
, device = device , device = device
, route = MyCollection , route = route
, page = Collection pageModel , page = page
} }
, Cmd.batch , Cmd.batch
[ UI.getViewport ViewportChanged [ 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 -> ( Model, Cmd Msg )
update msg model = update msg model =
case ( msg, model.page ) of case ( msg, model.page ) of
( UrlChanged url, Collection pageModel ) ->
Collection.update (Collection.UrlChanged url) pageModel
|> updateWith Collection CollectionMsg model
( UrlChanged url, _ ) -> ( UrlChanged url, _ ) ->
( model, Cmd.none ) let
oldRoute =
model.route
( LinkClicked _, _ ) -> newRoute =
( model, Cmd.none ) 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, _ ) -> ( ViewportChanged viewport, _ ) ->
( { model ( { model
@ -124,11 +150,11 @@ update msg model =
) )
( SpinnerMsg spinnerMsg, Collection pageModel ) -> ( SpinnerMsg spinnerMsg, Collection pageModel ) ->
Collection.update (Collection.SpinnerMsg spinnerMsg) pageModel Pages.Collection.update (Pages.Collection.SpinnerMsg spinnerMsg) pageModel
|> updateWith Collection CollectionMsg model |> updateWith Collection CollectionMsg model
( CollectionMsg pageMsg, Collection pageModel ) -> ( CollectionMsg pageMsg, Collection pageModel ) ->
Collection.update pageMsg pageModel Pages.Collection.update pageMsg pageModel
|> updateWith Collection CollectionMsg model |> updateWith Collection CollectionMsg model
( _, _ ) -> ( _, _ ) ->
@ -138,15 +164,21 @@ update msg model =
navBar : Model -> E.Element Msg navBar : Model -> E.Element Msg
navBar model = navBar model =
let let
navLink : Route -> String -> E.Element Msg navLink : Route.Route -> String -> E.Element Msg
navLink route text = navLink route text =
E.link E.link
[ E.pointer [ E.pointer
, E.padding 10 , E.padding 10
, Font.center , 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 in
E.row E.row
[ E.padding 10 [ E.padding 10
@ -156,10 +188,9 @@ navBar model =
, Font.color UI.colors.text , Font.color UI.colors.text
] ]
[ E.el [ E.width <| E.fillPortion 1 ] <| E.text "Tutor" [ E.el [ E.width <| E.fillPortion 1 ] <| E.text "Tutor"
, E.row [ E.width <| E.fillPortion 4 ] , E.row [ E.width <| E.fillPortion 4, E.spacing 3 ]
[ navLink MyCollection "Collection" [ navLink Route.Collection "Collection"
, navLink Route.DeckList "Decks"
-- , navLink "" "Decks"
] ]
] ]
@ -170,10 +201,32 @@ view model =
viewPage page = viewPage page =
case page of case page of
Collection pageModel -> Collection pageModel ->
E.map CollectionMsg <| Collection.view pageModel E.map CollectionMsg <| Pages.Collection.view pageModel
_ -> DeckList pageModel ->
E.none 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 in
{ title = "Tutor" { title = "Tutor"
, body = , body =
@ -186,7 +239,8 @@ view model =
[ E.width E.fill [ E.width E.fill
, E.height E.fill , E.height E.fill
] ]
[ viewPage model.page [ navBar model
, viewPage model.page
] ]
] ]
} }
@ -202,7 +256,7 @@ subscriptions model =
in in
case model.page of case model.page of
Collection pageModel -> Collection pageModel ->
Sub.batch (Sub.map CollectionMsg (Collection.subscriptions pageModel) :: global) Sub.batch (Sub.map CollectionMsg (Pages.Collection.subscriptions pageModel) :: global)
_ -> _ ->
Sub.batch global Sub.batch global

View file

@ -1,4 +1,4 @@
module Collection exposing (..) module Pages.Collection exposing (..)
import Browser import Browser
import Browser.Dom 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 , getViewport
, isMobile , isMobile
, manaSpinner , manaSpinner
, text
, title
) )
import Browser.Dom import Browser.Dom
import Color import Color
import Element as E import Element as E
import Element.Font as Font
import Spinner import Spinner
import Task import Task
@ -127,3 +130,13 @@ manaSpinner =
, width = 20 , width = 20
, color = color , 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