diff --git a/tutor/database.py b/tutor/database.py index 41d60a1..370ca33 100644 --- a/tutor/database.py +++ b/tutor/database.py @@ -346,6 +346,25 @@ async def store_deck_card( ) +async def get_decks( + db: psycopg.Cursor, limit: int = 10, offset: int = 0 +) -> typing.List[tutor.models.Deck]: + db.row_factory = psycopg.rows.dict_row + await db.execute( + """ + SELECT * FROM "decks" + ORDER BY "decks"."deck_id" + 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=[]) + for row in rows + ] + + async def store_var(db: psycopg.Cursor, key: str, value: str) -> None: await db.execute( """ diff --git a/tutor/models.py b/tutor/models.py index ac6445b..50e8868 100644 --- a/tutor/models.py +++ b/tutor/models.py @@ -115,5 +115,6 @@ class DeckCard: @dataclasses.dataclass class Deck: + deck_id: int name: str cards: typing.List[DeckCard] diff --git a/tutor/server.py b/tutor/server.py index 28fa6d9..fc5823c 100644 --- a/tutor/server.py +++ b/tutor/server.py @@ -58,7 +58,17 @@ class OpenAPIRequestHandler(tornado_openapi3.handler.OpenAPIRequestHandler): return spec -class SearchHandler(tornado.web.RequestHandler): +class RequestHandler(tornado.web.RequestHandler): + def set_links(self, **links) -> None: + self.set_header( + "Link", + ", ".join( + [f'<{self.url(url)}>; rel="{rel}"' for rel, url in links.items()] + ), + ) + + +class SearchHandler(RequestHandler): def url(self, url: str) -> str: scheme_override = self.application.settings["scheme"] if not scheme_override: @@ -74,14 +84,6 @@ class SearchHandler(tornado.web.RequestHandler): ) ) - def set_links(self, **links) -> None: - self.set_header( - "Link", - ", ".join( - [f'<{self.url(url)}>; rel="{rel}"' for rel, url in links.items()] - ), - ) - async def get(self) -> None: async with self.application.pool.connection() as conn: async with conn.cursor() as cursor: @@ -146,7 +148,7 @@ class SearchHandler(tornado.web.RequestHandler): ) -class CollectionHandler(tornado.web.RequestHandler): +class CollectionHandler(RequestHandler): async def get(self) -> None: async with self.application.pool.connection() as conn: async with conn.cursor() as cursor: @@ -157,19 +159,42 @@ class CollectionHandler(tornado.web.RequestHandler): ) -class DecksHandler(tornado.web.RequestHandler): +class DecksHandler(RequestHandler): async def get(self) -> None: - self.finish( - { - "decks": [], - } + page = max(1, int(self.get_argument("page", 1))) + limit = int(self.get_argument("limit", 10)) + async with self.application.pool.connection() as conn: + async with conn.cursor() as cursor: + decks = await tutor.database.get_decks( + cursor, limit=limit + 1, offset=limit * (page - 1) + ) + has_more = decks and len(decks) > limit + self.set_header("Content-Type", "application/json") + self.set_header("Access-Control-Allow-Origin", "*") + links = {} + if page > 1: + links["prev"] = update_args(self.request.full_url(), page=page - 1) + if has_more: + links["next"] = update_args(self.request.full_url(), page=page + 1) + self.set_links(**links) + self.write( + json.dumps( + [ + { + "deck_id": deck.deck_id, + "name": deck.name, + "cards": [], + } + for deck in decks + ] + ) ) async def post(self) -> None: ... -class TemplateHandler(tornado.web.RequestHandler): +class TemplateHandler(RequestHandler): def initialize( self, path: str, @@ -193,6 +218,7 @@ class StaticFileHandler(tornado.web.StaticFileHandler): 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__) diff --git a/www/src/App.elm b/www/src/App.elm index 5ca780a..f9ab25e 100644 --- a/www/src/App.elm +++ b/www/src/App.elm @@ -157,6 +157,10 @@ update msg model = Pages.Collection.update pageMsg pageModel |> updateWith Collection CollectionMsg model + ( DeckListMsg pageMsg, DeckList pageModel ) -> + Pages.DeckList.update pageMsg pageModel + |> updateWith DeckList DeckListMsg model + ( _, _ ) -> ( model, Cmd.none ) @@ -258,6 +262,9 @@ subscriptions model = Collection pageModel -> Sub.batch (Sub.map CollectionMsg (Pages.Collection.subscriptions pageModel) :: global) + DeckList pageModel -> + Sub.batch (Sub.map DeckListMsg (Pages.DeckList.subscriptions pageModel) :: global) + _ -> Sub.batch global diff --git a/www/src/Deck.elm b/www/src/Deck.elm new file mode 100644 index 0000000..2ff9b5b --- /dev/null +++ b/www/src/Deck.elm @@ -0,0 +1,17 @@ +module Deck exposing (..) + +import Json.Decode +import Json.Decode.Pipeline as JDP + + +type alias Deck = + { id : Int + , name : String + } + + +decode : Json.Decode.Decoder Deck +decode = + Json.Decode.succeed Deck + |> JDP.required "deck_id" Json.Decode.int + |> JDP.required "name" Json.Decode.string diff --git a/www/src/Pages/DeckList.elm b/www/src/Pages/DeckList.elm index 166e0ef..c6eede6 100644 --- a/www/src/Pages/DeckList.elm +++ b/www/src/Pages/DeckList.elm @@ -1,21 +1,42 @@ module Pages.DeckList exposing (..) +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 Model = { navigationKey : Browser.Navigation.Key , url : Url.Url , device : E.Device + , spinner : Spinner.Model + , deckPage : Deckpage } type Msg - = None + = ViewportChanged UI.Dimensions + | SpinnerMsg Spinner.Msg + | GotDecks (Result Http.Error (Paginated.Page Deck.Deck)) + | DeckClicked Int + + +type Deckpage + = Ready (Paginated.Page Deck.Deck) + | Loading (Paginated.Page Deck.Deck) + | Failed init : Browser.Navigation.Key -> Url.Url -> E.Device -> ( Model, Cmd Msg ) @@ -23,11 +44,88 @@ init key url device = ( { navigationKey = key , url = url , device = device + , spinner = Spinner.init + , deckPage = Loading Paginated.empty } - , Cmd.none + , getDecks ) +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 ) + + GotDecks (Ok deckPage) -> + ( { model | deckPage = Ready deckPage }, Cmd.none ) + + GotDecks (Err _) -> + ( { model | deckPage = Failed }, Cmd.none ) + + DeckClicked deckId -> + ( model, Browser.Navigation.pushUrl model.navigationKey (Route.toUrl (Route.Deck deckId)) ) + + view : Model -> E.Element Msg view model = - E.column [ E.padding 40, E.centerX, Font.italic ] [ UI.text "No decks yet" ] + case model.deckPage of + Failed -> + E.none + + Loading _ -> + E.el [ E.height E.fill, E.centerX ] <| + E.html <| + Spinner.view UI.manaSpinner + model.spinner + + Ready deckPage -> + if deckPage == Paginated.empty then + E.column [ E.padding 40, E.centerX, Font.italic ] [ UI.text "No decks yet" ] + + else + let + deckRow deck = + E.row + [ E.width E.fill + , E.padding 10 + , E.spacing 10 + , E.pointer + , E.mouseOver [ Background.color UI.colors.hover ] + , Events.onClick <| DeckClicked deck.id + ] + [ UI.title deck.name ] + in + E.column [ E.width E.fill, E.centerX ] <| List.map deckRow deckPage.values + + +subscriptions : Model -> Sub Msg +subscriptions model = + Sub.batch + [ Browser.Events.onResize + (\w h -> ViewportChanged { width = w, height = h }) + , Sub.map SpinnerMsg Spinner.subscription + ] + + +getDecks : Cmd Msg +getDecks = + loadPage <| + Url.Builder.absolute + [ "api", "decks" ] + [ Url.Builder.int "limit" 18 ] + + +loadPage : String -> Cmd Msg +loadPage url = + Http.get + { url = url + , expect = Paginated.expectJson GotDecks Deck.decode + } diff --git a/www/src/Route.elm b/www/src/Route.elm index 42d7e3a..07ef480 100644 --- a/www/src/Route.elm +++ b/www/src/Route.elm @@ -2,12 +2,13 @@ module Route exposing (Route(..), fromUrl, toUrl) import Url exposing (Url) import Url.Builder -import Url.Parser exposing (Parser, map, oneOf, parse, s, top) +import Url.Parser exposing ((), Parser, int, map, oneOf, parse, s, top) type Route = Collection | DeckList + | Deck Int parser : Parser (Route -> a) a @@ -16,19 +17,22 @@ parser = [ map Collection top , map Collection (s "collection") , map DeckList (s "decks") + , map Deck (s "decks" int) ] toUrl : Route -> String toUrl route = case route of - Collection -> Url.Builder.absolute [ "collection" ] [] DeckList -> Url.Builder.absolute [ "decks" ] [] + Deck deckId -> + Url.Builder.absolute [ "decks", String.fromInt deckId ] [] + fromUrl : Url.Url -> Maybe Route fromUrl url =