From d51554f4354be5f4e7785cf08ed5c0bb115911fa Mon Sep 17 00:00:00 2001 From: Correl Roush Date: Tue, 10 Jan 2023 12:31:31 -0500 Subject: [PATCH] Add deck initial editor page with card listing --- tutor/database.py | 106 +++++++++++++++++++++++++++-- tutor/server.py | 63 +++++++++++++++++- www/src/App.elm | 21 ++++++ www/src/Card.elm | 18 +++++ www/src/Deck.elm | 16 +++++ www/src/Pages/DeckEditor.elm | 125 +++++++++++++++++++++++++++++++++-- www/src/Pages/DeckList.elm | 2 +- www/src/UI.elm | 6 ++ 8 files changed, 345 insertions(+), 12 deletions(-) diff --git a/tutor/database.py b/tutor/database.py index 370ca33..daccf25 100644 --- a/tutor/database.py +++ b/tutor/database.py @@ -339,7 +339,7 @@ async def store_deck_card( ) -> None: 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) """, {"deck_id": deck_id, "oracle_id": str(oracle_id), "quantity": quantity}, @@ -352,19 +352,117 @@ async def get_decks( db.row_factory = psycopg.rows.dict_row 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" - LIMIT %(limit)s OFFSET %(offset)s + LIMIT %(limit)s OFFSET %(offset)s """, {"limit": limit, "offset": offset}, ) rows = await db.fetchall() 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 ] +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: await db.execute( """ diff --git a/tutor/server.py b/tutor/server.py index fc5823c..6b0d826 100644 --- a/tutor/server.py +++ b/tutor/server.py @@ -183,7 +183,20 @@ class DecksHandler(RequestHandler): { "deck_id": deck.deck_id, "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 ] @@ -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): def initialize( self, @@ -214,7 +263,14 @@ 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"): + if any( + re.match(pattern, path) + for pattern in [ + r"^collection$", + r"^decks$", + r"^decks/.*", + ] + ): path = "index.html" return tornado.web.StaticFileHandler.get_absolute_path(root, path) @@ -243,12 +299,13 @@ class Application(tornado.web.Application): (r"/api/search", SearchHandler), (r"/api/collection", CollectionHandler), (r"/api/decks", DecksHandler), + (r"/api/decks/([1-9][0-9]*)", DeckHandler), ] if static_path := settings.get("static"): paths.extend( [ ( - fr"/(.*)", + rf"/(.*)", StaticFileHandler, {"path": static_path, "default_filename": "index.html"}, ), diff --git a/www/src/App.elm b/www/src/App.elm index f9ab25e..3b0bc0f 100644 --- a/www/src/App.elm +++ b/www/src/App.elm @@ -18,6 +18,7 @@ import Http import Json.Decode import Maybe.Extra import Pages.Collection +import Pages.DeckEditor import Pages.DeckList import Paginated import Route @@ -46,6 +47,7 @@ type Msg | LinkClicked Browser.UrlRequest | CollectionMsg Pages.Collection.Msg | DeckListMsg Pages.DeckList.Msg + | DeckEditorMsg Pages.DeckEditor.Msg | SpinnerMsg Spinner.Msg @@ -53,6 +55,7 @@ type Page = NotFound | Collection Pages.Collection.Model | DeckList Pages.DeckList.Model + | DeckEditor Pages.DeckEditor.Model init : Json.Decode.Value -> Url.Url -> Browser.Navigation.Key -> ( Model, Cmd Msg ) @@ -81,6 +84,10 @@ init _ url key = initWith DeckList DeckListMsg <| Pages.DeckList.init key url device + Just (Route.Deck deckId) -> + initWith DeckEditor DeckEditorMsg <| + Pages.DeckEditor.init key url device deckId + _ -> ( NotFound, Cmd.none ) in @@ -128,6 +135,10 @@ update msg model = Pages.DeckList.init model.navigationKey url model.device |> 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 ) in @@ -161,6 +172,10 @@ update msg model = Pages.DeckList.update pageMsg pageModel |> updateWith DeckList DeckListMsg model + ( DeckEditorMsg pageMsg, DeckEditor pageModel ) -> + Pages.DeckEditor.update pageMsg pageModel + |> updateWith DeckEditor DeckEditorMsg model + ( _, _ ) -> ( model, Cmd.none ) @@ -210,6 +225,9 @@ view model = DeckList pageModel -> E.map DeckListMsg <| Pages.DeckList.view pageModel + DeckEditor pageModel -> + E.map DeckEditorMsg <| Pages.DeckEditor.view pageModel + NotFound -> E.column [ E.width E.fill @@ -265,6 +283,9 @@ subscriptions model = DeckList pageModel -> 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 diff --git a/www/src/Card.elm b/www/src/Card.elm index bbcff1a..e8688c0 100644 --- a/www/src/Card.elm +++ b/www/src/Card.elm @@ -13,6 +13,13 @@ type alias Prices = } +type alias Oracle = + { oracleId : String + , name : String + , oracleText : String + } + + type alias Card = { scryfallId : 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.succeed Card diff --git a/www/src/Deck.elm b/www/src/Deck.elm index 2ff9b5b..444ad92 100644 --- a/www/src/Deck.elm +++ b/www/src/Deck.elm @@ -1,5 +1,6 @@ module Deck exposing (..) +import Card import Json.Decode import Json.Decode.Pipeline as JDP @@ -7,6 +8,13 @@ import Json.Decode.Pipeline as JDP type alias Deck = { id : Int , name : String + , cards : List Card + } + + +type alias Card = + { oracle : Card.Oracle + , quantity : Int } @@ -15,3 +23,11 @@ decode = Json.Decode.succeed Deck |> JDP.required "deck_id" Json.Decode.int |> 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 diff --git a/www/src/Pages/DeckEditor.elm b/www/src/Pages/DeckEditor.elm index 74c4e70..35ce718 100644 --- a/www/src/Pages/DeckEditor.elm +++ b/www/src/Pages/DeckEditor.elm @@ -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 = - { cards : List Card.Card +type alias Model = + { 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 + } diff --git a/www/src/Pages/DeckList.elm b/www/src/Pages/DeckList.elm index c6eede6..0af25cc 100644 --- a/www/src/Pages/DeckList.elm +++ b/www/src/Pages/DeckList.elm @@ -96,7 +96,7 @@ view model = E.row [ E.width E.fill , E.padding 10 - , E.spacing 10 + , E.spacing 3 , E.pointer , E.mouseOver [ Background.color UI.colors.hover ] , Events.onClick <| DeckClicked deck.id diff --git a/www/src/UI.elm b/www/src/UI.elm index 03037ec..9ea7f24 100644 --- a/www/src/UI.elm +++ b/www/src/UI.elm @@ -4,6 +4,7 @@ module UI exposing , getViewport , isMobile , manaSpinner + , subtitle , text , title ) @@ -140,3 +141,8 @@ text string = title : String -> E.Element msg title 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