Add deck initial editor page with card listing

This commit is contained in:
Correl Roush 2023-01-10 12:31:31 -05:00
parent 32e213a16d
commit d51554f435
8 changed files with 345 additions and 12 deletions

View file

@ -339,7 +339,7 @@ async def store_deck_card(
) -> None: ) -> None:
await db.execute( await db.execute(
""" """
INSERT INTO "deck_cards" ("deck_id", "oracle_id", "quantity") INSERT INTO "deck_list" ("deck_id", "oracle_id", "quantity")
VALUES (%(deck_id)s, %(oracle_id)s, %(quantity)s) VALUES (%(deck_id)s, %(oracle_id)s, %(quantity)s)
""", """,
{"deck_id": deck_id, "oracle_id": str(oracle_id), "quantity": quantity}, {"deck_id": deck_id, "oracle_id": str(oracle_id), "quantity": quantity},
@ -352,7 +352,25 @@ async def get_decks(
db.row_factory = psycopg.rows.dict_row db.row_factory = psycopg.rows.dict_row
await db.execute( await db.execute(
""" """
SELECT * FROM "decks" SELECT "decks"."deck_id"
, "decks"."name"
, JSON_STRIP_NULLS(JSON_AGG(JSON_BUILD_OBJECT(
'oracle_id', "deck_list"."oracle_id",
'name', "oracle"."name",
'color_identity', "oracle"."color_identity",
'cmc', "oracle"."cmc",
'type_line', "oracle"."type_line",
'edhrec_rank', "oracle"."edhrec_rank",
'oracle_text', "oracle"."oracle_text",
'games', "oracle"."games",
'legalities', "oracle"."legalities",
'quantity', "deck_list"."quantity"
))) AS "cards"
FROM "decks"
LEFT JOIN "deck_list" USING ("deck_id")
LEFT JOIN "oracle" USING ("oracle_id")
GROUP BY "decks"."deck_id"
, "decks"."name"
ORDER BY "decks"."deck_id" ORDER BY "decks"."deck_id"
LIMIT %(limit)s OFFSET %(offset)s LIMIT %(limit)s OFFSET %(offset)s
""", """,
@ -360,11 +378,91 @@ async def get_decks(
) )
rows = await db.fetchall() rows = await db.fetchall()
return [ return [
tutor.models.Deck(deck_id=row["deck_id"], name=row["name"], cards=[]) tutor.models.Deck(
deck_id=row["deck_id"],
name=row["name"],
cards=[
tutor.models.DeckCard(
card=tutor.models.OracleCard(
oracle_id=card["oracle_id"],
name=card["name"],
color_identity=tutor.models.Color.from_string(
card["color_identity"]
),
cmc=card["cmc"],
type_line=card["type_line"],
games=set(),
legalities={},
edhrec_rank=card.get("edhrec_rank"),
oracle_text=card.get("oracle_text"),
),
quantity=card["quantity"],
)
for card in row["cards"]
if card and card.get("oracle_id")
],
)
for row in rows for row in rows
] ]
async def get_deck(
db: psycopg.Cursor, deck_id: int
) -> typing.Optional[tutor.models.Deck]:
db.row_factory = psycopg.rows.dict_row
await db.execute(
"""
SELECT "decks"."deck_id"
, "decks"."name"
, JSON_STRIP_NULLS(JSON_AGG(JSON_BUILD_OBJECT(
'oracle_id', "deck_list"."oracle_id",
'name', "oracle"."name",
'color_identity', "oracle"."color_identity",
'cmc', "oracle"."cmc",
'type_line', "oracle"."type_line",
'edhrec_rank', "oracle"."edhrec_rank",
'oracle_text', "oracle"."oracle_text",
'games', "oracle"."games",
'legalities', "oracle"."legalities",
'quantity', "deck_list"."quantity"
))) AS "cards"
FROM "decks"
LEFT JOIN "deck_list" USING ("deck_id")
LEFT JOIN "oracle" USING ("oracle_id")
WHERE "decks"."deck_id" = %(deck_id)s
GROUP BY "decks"."deck_id"
, "decks"."name"
""",
{"deck_id": deck_id},
)
row = await db.fetchone()
if row:
return tutor.models.Deck(
deck_id=row["deck_id"],
name=row["name"],
cards=[
tutor.models.DeckCard(
card=tutor.models.OracleCard(
oracle_id=card["oracle_id"],
name=card["name"],
color_identity=tutor.models.Color.from_string(
card["color_identity"]
),
cmc=card["cmc"],
type_line=card["type_line"],
games=set(),
legalities={},
edhrec_rank=card.get("edhrec_rank"),
oracle_text=card.get("oracle_text"),
),
quantity=card["quantity"],
)
for card in row["cards"]
if card and card.get("oracle_id")
],
)
async def store_var(db: psycopg.Cursor, key: str, value: str) -> None: async def store_var(db: psycopg.Cursor, key: str, value: str) -> None:
await db.execute( await db.execute(
""" """

View file

@ -183,7 +183,20 @@ class DecksHandler(RequestHandler):
{ {
"deck_id": deck.deck_id, "deck_id": deck.deck_id,
"name": deck.name, "name": deck.name,
"cards": [], "cards": [
{
"quantity": card.quantity,
"card": {
"oracle_id": str(card.card.oracle_id),
"name": card.card.name,
"color_identity": tutor.models.Color.to_string(
card.card.color_identity
),
"oracle_text": card.card.oracle_text,
},
}
for card in deck.cards
],
} }
for deck in decks for deck in decks
] ]
@ -194,6 +207,42 @@ class DecksHandler(RequestHandler):
... ...
class DeckHandler(RequestHandler):
async def get(self, deck_id) -> None:
self.set_header("Content-Type", "application/json")
self.set_header("Access-Control-Allow-Origin", "*")
async with self.application.pool.connection() as conn:
async with conn.cursor() as cursor:
deck = await tutor.database.get_deck(cursor, deck_id)
if not deck:
raise tornado.web.HTTPError(404)
self.write(
json.dumps(
{
"deck_id": deck.deck_id,
"name": deck.name,
"cards": [
{
"quantity": card.quantity,
"card": {
"oracle_id": str(card.card.oracle_id),
"name": card.card.name,
"color_identity": tutor.models.Color.to_string(
card.card.color_identity
),
"oracle_text": card.card.oracle_text,
},
}
for card in deck.cards
],
}
)
)
async def put(self, deck_id) -> None:
...
class TemplateHandler(RequestHandler): class TemplateHandler(RequestHandler):
def initialize( def initialize(
self, self,
@ -214,7 +263,14 @@ class StaticFileHandler(tornado.web.StaticFileHandler):
@classmethod @classmethod
def get_absolute_path(cls, root: str, path: str) -> str: def get_absolute_path(cls, root: str, path: str) -> str:
# Rewrite paths to load the index # Rewrite paths to load the index
if path in ("collection", "decks", "decks/new123123"): if any(
re.match(pattern, path)
for pattern in [
r"^collection$",
r"^decks$",
r"^decks/.*",
]
):
path = "index.html" path = "index.html"
return tornado.web.StaticFileHandler.get_absolute_path(root, path) return tornado.web.StaticFileHandler.get_absolute_path(root, path)
@ -243,12 +299,13 @@ 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), (r"/api/decks", DecksHandler),
(r"/api/decks/([1-9][0-9]*)", DeckHandler),
] ]
if static_path := settings.get("static"): if static_path := settings.get("static"):
paths.extend( paths.extend(
[ [
( (
fr"/(.*)", rf"/(.*)",
StaticFileHandler, StaticFileHandler,
{"path": static_path, "default_filename": "index.html"}, {"path": static_path, "default_filename": "index.html"},
), ),

View file

@ -18,6 +18,7 @@ import Http
import Json.Decode import Json.Decode
import Maybe.Extra import Maybe.Extra
import Pages.Collection import Pages.Collection
import Pages.DeckEditor
import Pages.DeckList import Pages.DeckList
import Paginated import Paginated
import Route import Route
@ -46,6 +47,7 @@ type Msg
| LinkClicked Browser.UrlRequest | LinkClicked Browser.UrlRequest
| CollectionMsg Pages.Collection.Msg | CollectionMsg Pages.Collection.Msg
| DeckListMsg Pages.DeckList.Msg | DeckListMsg Pages.DeckList.Msg
| DeckEditorMsg Pages.DeckEditor.Msg
| SpinnerMsg Spinner.Msg | SpinnerMsg Spinner.Msg
@ -53,6 +55,7 @@ type Page
= NotFound = NotFound
| Collection Pages.Collection.Model | Collection Pages.Collection.Model
| DeckList Pages.DeckList.Model | DeckList Pages.DeckList.Model
| DeckEditor Pages.DeckEditor.Model
init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg )
@ -81,6 +84,10 @@ init _ url key =
initWith DeckList DeckListMsg <| initWith DeckList DeckListMsg <|
Pages.DeckList.init key url device Pages.DeckList.init key url device
Just (Route.Deck deckId) ->
initWith DeckEditor DeckEditorMsg <|
Pages.DeckEditor.init key url device deckId
_ -> _ ->
( NotFound, Cmd.none ) ( NotFound, Cmd.none )
in in
@ -128,6 +135,10 @@ update msg model =
Pages.DeckList.init model.navigationKey url model.device Pages.DeckList.init model.navigationKey url model.device
|> updateWith DeckList DeckListMsg model |> updateWith DeckList DeckListMsg model
( _, Just (Route.Deck deckId) ) ->
Pages.DeckEditor.init model.navigationKey url model.device deckId
|> updateWith DeckEditor DeckEditorMsg model
_ -> _ ->
( { model | page = NotFound }, Cmd.none ) ( { model | page = NotFound }, Cmd.none )
in in
@ -161,6 +172,10 @@ update msg model =
Pages.DeckList.update pageMsg pageModel Pages.DeckList.update pageMsg pageModel
|> updateWith DeckList DeckListMsg model |> updateWith DeckList DeckListMsg model
( DeckEditorMsg pageMsg, DeckEditor pageModel ) ->
Pages.DeckEditor.update pageMsg pageModel
|> updateWith DeckEditor DeckEditorMsg model
( _, _ ) -> ( _, _ ) ->
( model, Cmd.none ) ( model, Cmd.none )
@ -210,6 +225,9 @@ view model =
DeckList pageModel -> DeckList pageModel ->
E.map DeckListMsg <| Pages.DeckList.view pageModel E.map DeckListMsg <| Pages.DeckList.view pageModel
DeckEditor pageModel ->
E.map DeckEditorMsg <| Pages.DeckEditor.view pageModel
NotFound -> NotFound ->
E.column E.column
[ E.width E.fill [ E.width E.fill
@ -265,6 +283,9 @@ subscriptions model =
DeckList pageModel -> DeckList pageModel ->
Sub.batch (Sub.map DeckListMsg (Pages.DeckList.subscriptions pageModel) :: global) Sub.batch (Sub.map DeckListMsg (Pages.DeckList.subscriptions pageModel) :: global)
DeckEditor pageModel ->
Sub.batch (Sub.map DeckEditorMsg (Pages.DeckEditor.subscriptions pageModel) :: global)
_ -> _ ->
Sub.batch global Sub.batch global

View file

@ -13,6 +13,13 @@ type alias Prices =
} }
type alias Oracle =
{ oracleId : String
, name : String
, oracleText : String
}
type alias Card = type alias Card =
{ scryfallId : String { scryfallId : String
, name : String , name : String
@ -25,6 +32,17 @@ type alias Card =
} }
decodeOracle : Json.Decode.Decoder Oracle
decodeOracle =
Json.Decode.succeed Oracle
|> JDP.required "oracle_id" Json.Decode.string
|> JDP.required "name" Json.Decode.string
|> JDP.required "oracle_text"
(Json.Decode.nullable Json.Decode.string
|> Json.Decode.map (Maybe.withDefault "")
)
decode : Json.Decode.Decoder Card decode : Json.Decode.Decoder Card
decode = decode =
Json.Decode.succeed Card Json.Decode.succeed Card

View file

@ -1,5 +1,6 @@
module Deck exposing (..) module Deck exposing (..)
import Card
import Json.Decode import Json.Decode
import Json.Decode.Pipeline as JDP import Json.Decode.Pipeline as JDP
@ -7,6 +8,13 @@ import Json.Decode.Pipeline as JDP
type alias Deck = type alias Deck =
{ id : Int { id : Int
, name : String , name : String
, cards : List Card
}
type alias Card =
{ oracle : Card.Oracle
, quantity : Int
} }
@ -15,3 +23,11 @@ decode =
Json.Decode.succeed Deck Json.Decode.succeed Deck
|> JDP.required "deck_id" Json.Decode.int |> JDP.required "deck_id" Json.Decode.int
|> JDP.required "name" Json.Decode.string |> JDP.required "name" Json.Decode.string
|> JDP.required "cards" (Json.Decode.list decodeCard)
decodeCard : Json.Decode.Decoder Card
decodeCard =
Json.Decode.succeed Card
|> JDP.required "card" Card.decodeOracle
|> JDP.required "quantity" Json.Decode.int

View file

@ -1,8 +1,125 @@
module DeckEditor exposing (..) module Pages.DeckEditor exposing (..)
import Card import Browser
import Browser.Events
import Browser.Navigation
import Deck
import Element as E
import Element.Background as Background
import Element.Events as Events
import Element.Font as Font
import Http
import Paginated
import Route
import Spinner
import UI
import Url
import Url.Builder
type alias Deck = type alias Model =
{ cards : List Card.Card { navigationKey : Browser.Navigation.Key
, url : Url.Url
, device : E.Device
, spinner : Spinner.Model
, deck : Deck
}
type Msg
= ViewportChanged UI.Dimensions
| SpinnerMsg Spinner.Msg
| GotDeck (Result Http.Error Deck.Deck)
type Deck
= Ready Deck.Deck
| Loading
| NotFound
| Failed
init : Browser.Navigation.Key -> Url.Url -> E.Device -> Int -> ( Model, Cmd Msg )
init key url device deckId =
( { navigationKey = key
, url = url
, device = device
, spinner = Spinner.init
, deck = Loading
}
, getDeck deckId
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ViewportChanged viewport ->
( { model
| device = E.classifyDevice viewport
}
, Cmd.none
)
SpinnerMsg msg_ ->
( { model | spinner = Spinner.update msg_ model.spinner }, Cmd.none )
GotDeck (Ok deck) ->
( { model | deck = Ready deck }, Cmd.none )
GotDeck (Err _) ->
( { model | deck = Failed }, Cmd.none )
view : Model -> E.Element Msg
view model =
case model.deck of
Failed ->
E.none
NotFound ->
E.none
Loading ->
E.el [ E.height E.fill, E.centerX ] <|
E.html <|
Spinner.view UI.manaSpinner
model.spinner
Ready deck ->
let
cardRow card =
E.row
[ E.width E.fill
, E.spacing 10
, E.padding 3
]
[ E.column [ E.centerY, E.height E.fill, E.width E.fill, E.clipX ]
[ UI.title card.oracle.name
, UI.subtitle ("x" ++ String.fromInt card.quantity)
]
]
in
E.column [ E.width E.fill, E.centerX, E.spacing 5 ] <|
[ E.paragraph [ Font.heavy, Font.size 24, Font.center ] [ UI.title deck.name ] ]
++ List.map cardRow deck.cards
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ Browser.Events.onResize
(\w h -> ViewportChanged { width = w, height = h })
, Sub.map SpinnerMsg Spinner.subscription
]
getDeck : Int -> Cmd Msg
getDeck deckId =
Http.get
{ url =
Url.Builder.absolute
[ "api", "decks", String.fromInt deckId ]
[]
, expect = Http.expectJson GotDeck Deck.decode
} }

View file

@ -96,7 +96,7 @@ view model =
E.row E.row
[ E.width E.fill [ E.width E.fill
, E.padding 10 , E.padding 10
, E.spacing 10 , E.spacing 3
, E.pointer , E.pointer
, E.mouseOver [ Background.color UI.colors.hover ] , E.mouseOver [ Background.color UI.colors.hover ]
, Events.onClick <| DeckClicked deck.id , Events.onClick <| DeckClicked deck.id

View file

@ -4,6 +4,7 @@ module UI exposing
, getViewport , getViewport
, isMobile , isMobile
, manaSpinner , manaSpinner
, subtitle
, text , text
, title , title
) )
@ -140,3 +141,8 @@ text string =
title : String -> E.Element msg title : String -> E.Element msg
title string = title string =
E.el [ Font.color colors.title ] <| E.text string E.el [ Font.color colors.title ] <| E.text string
subtitle : String -> E.Element msg
subtitle string =
E.el [ Font.size 16, Font.italic, Font.color colors.subtitle ] <| E.text string