Add UI and Dockerfile
This commit is contained in:
parent
866493b289
commit
a38465fc77
12 changed files with 362 additions and 14 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,7 +3,6 @@
|
|||
*.log
|
||||
tmp/
|
||||
*.egg-info
|
||||
poetry.lock
|
||||
|
||||
*.py[cod]
|
||||
*.egg
|
||||
|
|
41
Dockerfile
Normal file
41
Dockerfile
Normal file
|
@ -0,0 +1,41 @@
|
|||
FROM python:3.9.6-alpine3.13 as base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base as builder
|
||||
|
||||
ENV PIP_DEFAULT_TIMEOUT=100 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
POETRY_VERSION=1.1.4
|
||||
|
||||
RUN apk add --no-cache gcc libffi-dev musl-dev openssl-dev rust cargo
|
||||
RUN pip install "poetry==$POETRY_VERSION"
|
||||
RUN python -m venv /venv
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN poetry export -f requirements.txt | /venv/bin/pip install -r /dev/stdin
|
||||
|
||||
COPY . .
|
||||
RUN poetry build && /venv/bin/pip install dist/*.whl
|
||||
|
||||
FROM base as frontend
|
||||
|
||||
RUN apk add --no-cache make npm
|
||||
COPY www /www
|
||||
RUN make -C /www
|
||||
|
||||
FROM base as final
|
||||
|
||||
EXPOSE 8888
|
||||
|
||||
ENV TUTOR_PORT=8888 \
|
||||
TUTOR_DATABASE=/tutor.db \
|
||||
TUTOR_STATIC=/www/public
|
||||
|
||||
RUN apk add sqlite
|
||||
COPY --from=builder /venv /venv
|
||||
COPY --from=frontend /www /www
|
||||
COPY docker-entrypoint.sh tables.sql ./
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["server"]
|
7
docker-entrypoint.sh
Executable file
7
docker-entrypoint.sh
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
. /venv/bin/activate
|
||||
|
||||
sqlite3 ${TUTOR_DATABASE} <./tables.sql
|
||||
exec tutor $@
|
|
@ -15,7 +15,12 @@ import tutor.server
|
|||
|
||||
|
||||
@click.group()
|
||||
@click.option("--database", type=click.Path(dir_okay=False), required=True)
|
||||
@click.option(
|
||||
"--database",
|
||||
envvar="TUTOR_DATABASE",
|
||||
type=click.Path(dir_okay=False),
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
type=click.Choice(
|
||||
|
@ -39,11 +44,18 @@ def cli(ctx, database, log_level):
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--port", type=int, default=8888)
|
||||
@click.option("--port", type=int, envvar="TUTOR_PORT", default=8888)
|
||||
@click.option("--static", envvar="TUTOR_STATIC", type=click.Path(file_okay=False))
|
||||
@click.option("--debug", is_flag=True)
|
||||
@click.pass_context
|
||||
def server(ctx, port, debug):
|
||||
app = tutor.server.make_app({**ctx.obj, "debug": debug})
|
||||
def server(ctx, port, static, debug):
|
||||
app = tutor.server.make_app(
|
||||
{
|
||||
**ctx.obj,
|
||||
"static": static,
|
||||
"debug": debug,
|
||||
}
|
||||
)
|
||||
app.listen(port)
|
||||
tornado.ioloop.IOLoop.current().start()
|
||||
|
||||
|
|
|
@ -19,8 +19,10 @@ async def search(
|
|||
scryfall_id: typing.Optional[str] = None,
|
||||
limit: int = 10,
|
||||
distinct: bool = True,
|
||||
in_collection: typing.Optional[bool] = None,
|
||||
) -> typing.List[tutor.models.Card]:
|
||||
db.row_factory = aiosqlite.Row
|
||||
joins = []
|
||||
constraints = []
|
||||
params = {}
|
||||
if name is not None:
|
||||
|
@ -44,10 +46,17 @@ async def search(
|
|||
if scryfall_id is not None:
|
||||
constraints.append("cards.scryfall_id LIKE :scryfall_id")
|
||||
params["scryfall_id"] = scryfall_id
|
||||
if in_collection is not None:
|
||||
if in_collection:
|
||||
joins.append("JOIN copies USING (scryfall_id)")
|
||||
else:
|
||||
joins.append("LEFT JOIN copies USING (scryfall_id)")
|
||||
constraints.append("copies.id IS NULL")
|
||||
joins.append("JOIN sets USING (set_code)")
|
||||
query = " ".join(
|
||||
[
|
||||
"SELECT cards.* FROM cards",
|
||||
"JOIN sets USING (set_code)",
|
||||
" ".join(joins),
|
||||
"WHERE" if constraints else "",
|
||||
" AND ".join(constraints),
|
||||
f"LIMIT {limit}",
|
||||
|
|
|
@ -10,10 +10,17 @@ import tutor.models
|
|||
class SearchHandler(tornado.web.RequestHandler):
|
||||
async def get(self) -> None:
|
||||
async with aiosqlite.connect(self.application.settings["database"]) as db:
|
||||
name = self.get_argument("name", None)
|
||||
in_collection = self.get_argument("in_collection", None)
|
||||
limit = int(self.get_argument("limit", 10))
|
||||
cards = await tutor.database.search(
|
||||
db, name=self.get_argument("name", None)
|
||||
db,
|
||||
name=f"%{name}%" if name else None,
|
||||
in_collection=in_collection.lower() == "yes" if in_collection else None,
|
||||
limit=limit,
|
||||
)
|
||||
self.set_header("Content-Type", "application/json")
|
||||
self.set_header("Access-Control-Allow-Origin", "*")
|
||||
self.write(
|
||||
json.dumps(
|
||||
[
|
||||
|
@ -22,7 +29,7 @@ class SearchHandler(tornado.web.RequestHandler):
|
|||
"name": card.name,
|
||||
"set_code": card.set_code,
|
||||
"collector_number": card.collector_number,
|
||||
"rarity": card.rarity,
|
||||
"rarity": str(card.rarity),
|
||||
"color_identity": tutor.models.Color.to_string(
|
||||
card.color_identity
|
||||
),
|
||||
|
@ -34,9 +41,16 @@ class SearchHandler(tornado.web.RequestHandler):
|
|||
|
||||
|
||||
def make_app(settings) -> tornado.web.Application:
|
||||
return tornado.web.Application(
|
||||
[
|
||||
(r"/", SearchHandler),
|
||||
],
|
||||
**settings,
|
||||
)
|
||||
paths = [
|
||||
(r"/search", SearchHandler),
|
||||
]
|
||||
if static_path := settings.get("static"):
|
||||
paths.extend(
|
||||
[
|
||||
(r"/", tornado.web.RedirectHandler, {"url": "/app/index.html"}),
|
||||
(r"/app", tornado.web.RedirectHandler, {"url": "/app/index.html"}),
|
||||
(r"/app/", tornado.web.RedirectHandler, {"url": "/app/index.html"}),
|
||||
(r"/app/(.*)", tornado.web.StaticFileHandler, {"path": static_path}),
|
||||
]
|
||||
)
|
||||
return tornado.web.Application(paths, **settings)
|
||||
|
|
4
www/.gitignore
vendored
Normal file
4
www/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
elm-stuff
|
||||
node_modules
|
||||
package-lock.json
|
||||
public/elm.js
|
33
www/Makefile
Normal file
33
www/Makefile
Normal file
|
@ -0,0 +1,33 @@
|
|||
.PHONY: all node-deps clean run
|
||||
|
||||
TARGET=public/elm.js
|
||||
SOURCE=src/App.elm
|
||||
|
||||
ELM_FILES = $(shell find src -type f -name '*.elm')
|
||||
NODE_BIN = ./node_modules/.bin
|
||||
ELM = $(NODE_BIN)/elm
|
||||
ELM_LIVE = $(NODE_BIN)/elm-live
|
||||
|
||||
ELMMAKE_FLAGS =
|
||||
ifeq ($(DEBUG),1)
|
||||
ELMMAKE_FLAGS += --debug
|
||||
endif
|
||||
|
||||
all: node-deps $(TARGET)
|
||||
|
||||
node-deps:
|
||||
npm i
|
||||
|
||||
$(TARGET): $(ELM_FILES)
|
||||
$(ELM) make $(ELMMAKE_FLAGS) $(SOURCE) --output $@
|
||||
|
||||
clean-deps:
|
||||
rm -rf elm-stuff
|
||||
rm -rf node_modules
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET)
|
||||
rm -rf elm-stuff/build-artifacts
|
||||
|
||||
run: all
|
||||
PATH="$(NODE_BIN):$$PATH" $(ELM_LIVE) $(SOURCE) --dir public --open -- $(ELMMAKE_FLAGS) --output $(TARGET)
|
29
www/elm.json
Normal file
29
www/elm.json
Normal file
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/http": "2.0.0",
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0",
|
||||
"mdgriffith/elm-ui": "1.1.8"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/bytes": "1.0.8",
|
||||
"elm/file": "1.0.5",
|
||||
"elm/time": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
13
www/package.json
Normal file
13
www/package.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "tutor",
|
||||
"description": "Tutor",
|
||||
"version": "1.0.0",
|
||||
"author": "Correl Roush <correl@gmail.com>",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"elm": "0.19.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"elm-live": "^4.0.2"
|
||||
}
|
||||
}
|
11
www/public/index.html
Normal file
11
www/public/index.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Bulk Tagging Dashboard</title>
|
||||
<script type="text/javascript" src="elm.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript">
|
||||
var app = Elm.App.init({flags: {}});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
176
www/src/App.elm
Normal file
176
www/src/App.elm
Normal file
|
@ -0,0 +1,176 @@
|
|||
module App exposing (main)
|
||||
|
||||
import Browser
|
||||
import Element as E
|
||||
import Element.Input as EI
|
||||
import Html
|
||||
import Http
|
||||
import Json.Decode
|
||||
import Json.Decode.Pipeline as JDP
|
||||
import Url
|
||||
import Url.Builder
|
||||
|
||||
|
||||
type alias Criteria =
|
||||
{ name : String
|
||||
, ownedOnly : Bool
|
||||
}
|
||||
|
||||
|
||||
type alias Card =
|
||||
{ scryfallId : String
|
||||
, name : String
|
||||
, setCode : String
|
||||
, rarity : String
|
||||
}
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ criteria : Criteria
|
||||
, cards : List Card
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= UrlChanged Url.Url
|
||||
| LinkClicked Browser.UrlRequest
|
||||
| UpdateCriteria CriteriaMsg
|
||||
| Search Criteria
|
||||
| FoundCards (Result Http.Error (List Card))
|
||||
|
||||
|
||||
type CriteriaMsg
|
||||
= UpdateName String
|
||||
| UpdateOwnedOnly Bool
|
||||
|
||||
|
||||
search : Criteria -> Cmd Msg
|
||||
search criteria =
|
||||
Http.get
|
||||
{ url =
|
||||
Url.Builder.absolute
|
||||
[ "search" ]
|
||||
[ Url.Builder.string "name" criteria.name
|
||||
, Url.Builder.string "in_collection"
|
||||
(if criteria.ownedOnly then
|
||||
"yes"
|
||||
|
||||
else
|
||||
""
|
||||
)
|
||||
, Url.Builder.int "limit" 20
|
||||
]
|
||||
, expect = Http.expectJson FoundCards (Json.Decode.list decodeCard)
|
||||
}
|
||||
|
||||
|
||||
decodeCard : Json.Decode.Decoder Card
|
||||
decodeCard =
|
||||
Json.Decode.succeed Card
|
||||
|> JDP.required "scryfall_id" Json.Decode.string
|
||||
|> JDP.required "name" Json.Decode.string
|
||||
|> JDP.required "set_code" Json.Decode.string
|
||||
|> JDP.required "rarity" Json.Decode.string
|
||||
|
||||
|
||||
init : Json.Decode.Value -> url -> key -> ( Model, Cmd Msg )
|
||||
init flag url key =
|
||||
let
|
||||
criteria =
|
||||
{ name = "", ownedOnly = True }
|
||||
in
|
||||
( { criteria = criteria
|
||||
, cards = []
|
||||
}
|
||||
, search criteria
|
||||
)
|
||||
|
||||
|
||||
updateCriteria msg model =
|
||||
case msg of
|
||||
UpdateName text ->
|
||||
{ model | name = text }
|
||||
|
||||
UpdateOwnedOnly value ->
|
||||
{ model | ownedOnly = value }
|
||||
|
||||
|
||||
update msg model =
|
||||
case msg of
|
||||
UpdateCriteria criteriaMsg ->
|
||||
let
|
||||
newCriteria =
|
||||
updateCriteria criteriaMsg model.criteria
|
||||
in
|
||||
( { model | criteria = newCriteria }, search newCriteria )
|
||||
|
||||
FoundCards (Ok cards) ->
|
||||
( { model | cards = cards }, Cmd.none )
|
||||
|
||||
_ ->
|
||||
( model, Cmd.none )
|
||||
|
||||
|
||||
viewCard model =
|
||||
E.column []
|
||||
[ -- E.row [ E.spacing 5 ]
|
||||
-- [ E.text model.name
|
||||
-- , E.image [ E.width <| E.px 20, E.height <| E.px 20 ]
|
||||
-- { src =
|
||||
-- if String.length model.setCode == 3 then
|
||||
-- String.concat [ "images/sets/", String.toLower model.setCode, ".svg" ]
|
||||
-- else
|
||||
-- "images/sets/default.svg"
|
||||
-- , description = model.setCode
|
||||
-- }
|
||||
-- ]
|
||||
-- ,
|
||||
E.image [ E.height <| E.px 300 ]
|
||||
{ src =
|
||||
Url.Builder.crossOrigin "https://api.scryfall.com"
|
||||
[ "cards", model.scryfallId ]
|
||||
[ Url.Builder.string "format" "image"
|
||||
, Url.Builder.string "version" "border_crop"
|
||||
]
|
||||
, description = model.name
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
view model =
|
||||
{ title = "Tutor"
|
||||
, body =
|
||||
[ E.layout [] <|
|
||||
E.column []
|
||||
[ E.row []
|
||||
[ EI.text []
|
||||
{ onChange = UpdateCriteria << UpdateName
|
||||
, text = model.criteria.name
|
||||
, placeholder = Nothing
|
||||
, label = EI.labelHidden "Search Input"
|
||||
}
|
||||
, EI.checkbox []
|
||||
{ onChange = UpdateCriteria << UpdateOwnedOnly
|
||||
, icon = EI.defaultCheckbox
|
||||
, checked = model.criteria.ownedOnly
|
||||
, label = EI.labelRight [] (E.text "Owned only?")
|
||||
}
|
||||
]
|
||||
, E.wrappedRow [ E.spacing 5 ] <| List.map viewCard model.cards
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
main =
|
||||
Browser.application
|
||||
{ init = init
|
||||
, onUrlChange = UrlChanged
|
||||
, onUrlRequest = LinkClicked
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions =
|
||||
\_ ->
|
||||
Sub.batch
|
||||
[]
|
||||
}
|
Loading…
Reference in a new issue