4 次代码提交 8d74d8a701 ... 2683cffca0

作者 SHA1 备注 提交日期
  Cadel Watson 2683cffca0 Add docs 4 月之前
  Cadel Watson 01e5d152c2 Improve styles and navigation; add mobile warning 4 月之前
  Cadel Watson 9339ad2982 Load draft from 17lands public URL 4 月之前
  Cadel Watson 3f2d7f6622 WIP: load drafts straight from 17lands 4 月之前
共有 14 个文件被更改,包括 454 次插入116 次删除
  1. 71 6
      README.md
  2. 4 0
      css/styles.css
  3. 39 39
      elm.json
  4. 1 1
      html/index.html
  5. 2 8
      js/app.js
  6. 15 0
      requirements.txt
  7. 2 2
      server/main.py
  8. 24 1
      src/API.elm
  9. 20 0
      src/Database.elm
  10. 2 7
      src/Draft.elm
  11. 49 0
      src/DraftMeta.elm
  12. 198 51
      src/Main.elm
  13. 12 1
      tests/TestDatabase.elm
  14. 15 0
      tests/TestDraftMeta.elm

+ 71 - 6
README.md

@@ -1,16 +1,81 @@
 # Drafter
 
-# UI
+A web-based assistant for Magic: The Gathering Limited players. 
 
-Install with `npm install`.
+Load your drafts from [17Lands](https://www.17lands.com/) and analyse your picks.
 
-Run with `npm run start`.
+> ⚠️ **Early development**
+> 
+> Drafter is pre-alpha software. It's very incomplete. Expect bugs and breaking changes.
 
-Build with `npm run build`.
+---
 
-## Backend
+## Project structure
 
-## Adding a new set
+| Path | Purpose |
+| ---- | ------- |
+| `src/` | Elm source for the front-end UI |
+| `css/`, `tailwind.config.js` | Tailwind CSS styling |
+| `html/` | Static HTML entry point served by Parcel |
+| `server/` | FastAPI backend that exposes set/card endpoints |
+| `data/` | Raw data files and helper scripts used to generate JSON served by the backend |
+
+---
+
+## Prerequisites
+
+Front-end
+* Node ≥ 14 and npm (or yarn)
+
+Back-end
+* Python ≥ 3.10
+* (Recommended) `virtualenv` or `pyenv`
+
+---
+
+## Running locally
+
+### 1. Clone & install
+
+```bash
+# clone and enter the repo
+# git clone https://github.com/kdelwat/drafter.git
+cd drafter
+
+# Front-end deps
+npm install
+
+# Back-end deps (inside a venv)
+python3 -m venv .venv
+source .venv/bin/activate
+pip install fastapi uvicorn[standard] httpx
+```
+
+### 2. Start development servers
+
+| Service | Command | URL |
+| ------- | ------- | --- |
+| UI (Parcel) | `npm run start` | http://localhost:1234 |
+| API (FastAPI) | `uvicorn server.main:app --reload --port 8000` | http://localhost:8000 |
+
+The UI is configured to call the API on `localhost:8000` during development via CORS.
+
+### 3. Build for production (optional)
+
+```bash
+npm run build   # outputs static assets to dist/
+```
+
+### 4. Running tests
+
+There are a few frontend tests, run with:
+
+```bash
+elm-test
+```
+---
+
+## Generating & adding new set data
 
 0. Create a new folder in data/sets
 

+ 4 - 0
css/styles.css

@@ -326,3 +326,7 @@ body {
 ::-webkit-scrollbar-thumb:hover {
     background: #334155;
 }
+
+body {
+	background-color: rgb(241 245 249);
+}

+ 39 - 39
elm.json

@@ -1,43 +1,43 @@
 {
-  "type": "application",
-  "source-directories": [
-    "src"
-  ],
-  "elm-version": "0.19.1",
-  "dependencies": {
-    "direct": {
-      "NoRedInk/elm-json-decode-pipeline": "1.0.1",
-      "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/parser": "1.1.0",
-      "elm/svg": "1.0.1",
-      "elm-community/list-extra": "8.7.0",
-      "myrho/elm-round": "1.0.5",
-      "terezka/elm-charts": "4.0.0"
+    "type": "application",
+    "source-directories": [
+        "src"
+    ],
+    "elm-version": "0.19.1",
+    "dependencies": {
+        "direct": {
+            "NoRedInk/elm-json-decode-pipeline": "1.0.1",
+            "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/parser": "1.1.0",
+            "elm/svg": "1.0.1",
+            "elm/url": "1.0.0",
+            "elm-community/list-extra": "8.7.0",
+            "myrho/elm-round": "1.0.5",
+            "terezka/elm-charts": "4.0.0"
+        },
+        "indirect": {
+            "K-Adam/elm-dom": "1.0.0",
+            "danhandrea/elm-time-extra": "1.1.0",
+            "elm/bytes": "1.0.8",
+            "elm/file": "1.0.5",
+            "elm/time": "1.0.0",
+            "elm/virtual-dom": "1.0.2",
+            "justinmimbs/date": "4.1.0",
+            "justinmimbs/time-extra": "1.2.0",
+            "ryan-haskell/date-format": "1.0.0",
+            "terezka/intervals": "2.0.2"
+        }
     },
-    "indirect": {
-      "K-Adam/elm-dom": "1.0.0",
-      "danhandrea/elm-time-extra": "1.1.0",
-      "elm/bytes": "1.0.8",
-      "elm/file": "1.0.5",
-      "elm/time": "1.0.0",
-      "elm/url": "1.0.0",
-      "elm/virtual-dom": "1.0.2",
-      "justinmimbs/date": "4.1.0",
-      "justinmimbs/time-extra": "1.2.0",
-      "ryan-haskell/date-format": "1.0.0",
-      "terezka/intervals": "2.0.2"
+    "test-dependencies": {
+        "direct": {
+            "elm-explorations/test": "2.2.0"
+        },
+        "indirect": {
+            "elm/random": "1.0.0"
+        }
     }
-  },
-  "test-dependencies": {
-    "direct": {
-      "elm-explorations/test": "2.2.0"
-    },
-    "indirect": {
-      "elm/random": "1.0.0"
-    }
-  }
 }

+ 1 - 1
html/index.html

@@ -2,7 +2,7 @@
 <html lang="en">
   <head>
     <meta charset="utf-8"/>
-    <title>My First Parcel App</title>
+    <title>Drafter</title>
     <link rel="stylesheet" href="../css/styles.css" />
     <script type="module" src="../js/app.js"></script>
   </head>

+ 2 - 8
js/app.js

@@ -1,5 +1,3 @@
-import draftData from "../data/drafts/draft_tdm_bad.json";
-
 import {Elm} from '../src/Main.elm';
 
 function getSetData(db, setCode, callback) {
@@ -77,12 +75,9 @@ openDBRequest.onsuccess = (event) => {
             node: document.getElementById('myapp'),
             flags: {
                 sets: sets,
-                draftData: JSON.stringify(draftData),
             }
         });
 
-        console.log(app.ports);
-
         app.ports.sendDoesSetHaveLocalData.subscribe((setCode) => {
             getSetData(database, setCode, (data) => {
                 app.ports.receiveDoesSetHaveLocalData.send(JSON.stringify(data));
@@ -91,12 +86,11 @@ openDBRequest.onsuccess = (event) => {
 
         app.ports.sendSaveLocalData.subscribe((setData) => {
             saveSetData(database, setData);
-        })
+        });
 
         app.ports.sendDeleteLocalData.subscribe((setCode) => {
             deleteSetData(database, setCode, (setCode) => app.ports.receiveDidDeleteLocalData.send(setCode))
-        })
-
+        });
     })
 };
 

+ 15 - 0
requirements.txt

@@ -0,0 +1,15 @@
+annotated-types==0.7.0
+anyio==4.9.0
+certifi==2025.7.14
+click==8.1.8
+fastapi==0.115.12
+h11==0.16.0
+httpcore==1.0.9
+idna==3.10
+pydantic==2.11.3
+pydantic_core==2.33.1
+sniffio==1.3.1
+starlette==0.46.2
+typing-inspection==0.4.0
+typing_extensions==4.13.2
+uvicorn==0.34.1

+ 2 - 2
server/main.py

@@ -7,8 +7,8 @@ from fastapi.middleware.cors import CORSMiddleware
 app = FastAPI()
 
 origins = [
-    "http://drafter.cadel.me",
-    "https://drafter.cadel.me",
+    "http://drafter.cadelwatson.com",
+    "https://drafter.cadelwatson.com",
     "http://localhost",
     "http://localhost:1234",
 ]

+ 24 - 1
src/API.elm

@@ -1,8 +1,10 @@
-module API exposing (getSetData, getSets)
+module API exposing (getDraft, getDrafts, getSetData, getSets)
 
 import Database exposing (Database)
+import DraftMeta exposing (DraftMeta)
 import Http
 import Json.Decode as Decode
+import Url.Builder as UrlB
 
 
 apiUrl : String
@@ -40,6 +42,27 @@ getSetData setCode onSuccess =
         }
 
 
+getDrafts : String -> (Result String (List DraftMeta) -> msg) -> Cmd msg
+getDrafts historyUrl onSuccess =
+    Http.get
+        { url = apiUrl ++ UrlB.absolute [ "drafts" ] [ UrlB.string "history" historyUrl ]
+        , expect =
+            Http.expectJson (Result.mapError httpErrorToString >> onSuccess)
+                (Decode.field "drafts"
+                    (Decode.list DraftMeta.decoder)
+                )
+        }
+
+
+getDraft : String -> (Result String DraftMeta -> msg) -> Cmd msg
+getDraft draftID onSuccess =
+    Http.get
+        { url = "https://www.17lands.com" ++ UrlB.absolute [ "data", "draft" ] [ UrlB.string "draft_id" draftID ]
+        , expect =
+            Http.expectJson (Result.mapError httpErrorToString >> onSuccess) DraftMeta.decoder
+        }
+
+
 httpErrorToString : Http.Error -> String
 httpErrorToString error =
     case error of

+ 20 - 0
src/Database.elm

@@ -248,9 +248,29 @@ encodeCardDetails card =
         , ( "colors", Encode.list encodeManaColor card.details.colors )
         , ( "mana_cost", Encode.string card.details.rawManaCost )
         , ( "image_uris", Encode.object [ ( "large", Encode.string card.details.imageUrl ) ] )
+        , ( "rarity", encodeRarity card.details.rarity )
         ]
 
 
+encodeRarity : Rarity -> Encode.Value
+encodeRarity r =
+    case r of
+        Common ->
+            Encode.string "common"
+
+        Uncommon ->
+            Encode.string "uncommon"
+
+        Rare ->
+            Encode.string "rare"
+
+        Mythic ->
+            Encode.string "mythic"
+
+        NoRarity ->
+            Encode.string ""
+
+
 decodeCardDetails : Decoder ( String, CardDetails )
 decodeCardDetails =
     Decode.map2 pair

+ 2 - 7
src/Draft.elm

@@ -1,4 +1,4 @@
-module Draft exposing (Draft, Pick, PickCard, decode)
+module Draft exposing (Draft, Pick, PickCard, decodeDraft)
 
 import Json.Decode as Decode exposing (Decoder)
 import Json.Decode.Pipeline as Decode
@@ -22,14 +22,9 @@ type alias PickCard =
     }
 
 
-decode : String -> Result String Draft
-decode x =
-    Decode.decodeString decodeDraft x |> Result.mapError Decode.errorToString
-
-
 decodeDraft : Decoder Draft
 decodeDraft =
-    Decode.field "picks" (Decode.list decodePick)
+    Decode.list decodePick
         |> Decode.map Zipper.fromList
         |> Decode.andThen
             (\z ->

+ 49 - 0
src/DraftMeta.elm

@@ -0,0 +1,49 @@
+module DraftMeta exposing (DraftMeta, decoder, parseDraftIDFromPublicURL)
+
+import Draft exposing (Draft)
+import Json.Decode as Decode exposing (Decoder)
+import Json.Decode.Pipeline as Decode
+import Url
+import Url.Parser as UrlP exposing ((</>))
+
+
+type alias DraftMeta =
+    { setCode : String
+    , draftData : Draft
+    }
+
+
+decoder : Decoder DraftMeta
+decoder =
+    Decode.succeed DraftMeta
+        |> Decode.required "expansion" (Decode.string |> Decode.map (\s -> String.toLower s))
+        |> Decode.required "picks" Draft.decodeDraft
+
+
+{-| Parse the draft ID from the public URL.
+
+e.g. the public URL for draft ID `490bda1edc574d71a2768a7d7a415a25` is
+<https://www.17lands.com/draft/490bda1edc574d71a2768a7d7a415a25>
+
+-}
+parseDraftIDFromPublicURL : String -> Result String String
+parseDraftIDFromPublicURL url =
+    let
+        draftURLP =
+            UrlP.s "draft" </> UrlP.string
+    in
+    String.trim url
+        |> Url.fromString
+        |> (\maybeUrl ->
+                case maybeUrl of
+                    Just u ->
+                        case UrlP.parse draftURLP u of
+                            Just draftID ->
+                                Result.Ok draftID
+
+                            Nothing ->
+                                Result.Err "parsing failed"
+
+                    Nothing ->
+                        Result.Err "not a valid URL"
+           )

+ 198 - 51
src/Main.elm

@@ -10,9 +10,10 @@ import Database
 import Deck
 import Dict exposing (Dict)
 import Draft exposing (Draft)
-import Html exposing (Html, a, button, div, img, li, p, span, text, ul)
-import Html.Attributes exposing (alt, class, classList, disabled, src)
-import Html.Events as Events exposing (onClick, onMouseEnter)
+import DraftMeta exposing (DraftMeta, parseDraftIDFromPublicURL)
+import Html exposing (Html, a, button, div, h1, h2, h3, header, img, input, label, li, p, span, text, ul)
+import Html.Attributes exposing (alt, class, classList, disabled, href, src, type_, value)
+import Html.Events as Events exposing (onClick, onInput, onMouseEnter)
 import Html.Keyed as Keyed
 import Icon exposing (chevronDown, chevronUp)
 import Json.Decode exposing (decodeString)
@@ -72,9 +73,15 @@ type SetLoadStatus
     | DeletingLocalData
 
 
+type ImportDraft
+    = EditingImportUrl String
+    | LoadingDraft
+    | ImportDraftParseError String
+
+
 type alias ChooseSetModel =
-    { draftData : String
-    , sets : Dict String SetLoadStatus
+    { sets : Dict String SetLoadStatus
+    , importDraft : ImportDraft
     }
 
 
@@ -88,6 +95,9 @@ type alias ReadyModel =
     , deckProgress : DeckProgress
     , toolboxAccordion : ToolboxAccordion
     , deckSortOrder : Deck.DeckSortMethod
+
+    -- Save state for returning to menu
+    , sets : Dict String SetLoadStatus
     }
 
 
@@ -97,6 +107,9 @@ type alias CardExplorerModel =
     , flipHighlighted : Bool
     , performanceDistributions : CardPerformanceDistributions
     , sortOrder : SortOrder
+
+    -- Save state for returning to menu
+    , sets : Dict String SetLoadStatus
     }
 
 
@@ -105,7 +118,7 @@ type alias ErrorModel =
     }
 
 
-init : { sets : List String, draftData : String } -> ( Model, Cmd Msg )
+init : { sets : List String } -> ( Model, Cmd Msg )
 init flags =
     let
         setStatus : Dict String SetLoadStatus
@@ -121,17 +134,20 @@ init flags =
                 flags.sets
     in
     ( ChooseSet
-        { draftData = flags.draftData
-        , sets = setStatus
+        { sets = setStatus
+        , importDraft = EditingImportUrl ""
         }
     , Cmd.batch (API.getSets IOGotSets :: setCmds)
     )
 
 
 type Msg
-    = Increment
-    | Decrement
-    | Highlight String
+    = Increment -- forward one pick
+    | Decrement -- backward one pick
+    | Highlight String -- hover a card to highlight it
+    | ChangeDraftImportUrlField String
+    | AcknowledgeParseError
+    | SubmitImportDraft
     | FlipHighlightedCard
     | SetFocusStat FocusStat
     | SetSortOrder SortOrder
@@ -142,50 +158,48 @@ type Msg
     | ToggleDeckList
     | SetDeckSortMethod Deck.DeckSortMethod
     | IOGotSets (Result String (List String))
+    | IOGotDraft (Result String DraftMeta)
     | IOFetchSetData String
     | IOGotSetData (Result String ( String, Maybe Database.Database ))
     | IODeleteSetData String -- Delete by set code
     | IOGotDeleteSetData String -- Successful deletion by set code
     | PortReceiveDoesSetHaveLocalData String
-    | StartDraft String Database.Database
     | OpenCardExplorer String Database.Database
+    | BackToMenu
 
 
-startDraft : ChooseSetModel -> Database.Database -> ( Model, Cmd Msg )
-startDraft initFlags database =
-    case Draft.decode initFlags.draftData of
-        Ok draftData ->
-            ( Ready
-                { draft = draftData
-                , database = database
-                , performanceDistributions = calculatePerformanceDistributions (Database.getAll database)
-                , highlighted = Nothing
-                , flipHighlighted = False
-                , focusStat = FocusPickRate
-                , deckProgress = DeckUpToPick
-                , toolboxAccordion =
-                    { cmc = True
-                    , signals = False
-                    , signalsDelta = False
-                    , deckList = False
-                    }
-                , deckSortOrder = Deck.SortByPickNumber
-                }
-            , Cmd.none
-            )
-
-        Err err ->
-            ( Error { error = "Error decoding draft data: " ++ err }, Cmd.none )
+startDraft : Dict String SetLoadStatus -> DraftMeta -> Database.Database -> ( Model, Cmd Msg )
+startDraft sets draft database =
+    ( Ready
+        { draft = draft.draftData
+        , database = database
+        , performanceDistributions = calculatePerformanceDistributions (Database.getAll database)
+        , highlighted = Nothing
+        , flipHighlighted = False
+        , focusStat = FocusPickRate
+        , deckProgress = DeckUpToPick
+        , toolboxAccordion =
+            { cmc = True
+            , signals = False
+            , signalsDelta = False
+            , deckList = False
+            }
+        , deckSortOrder = Deck.SortByPickNumber
+        , sets = sets
+        }
+    , Cmd.none
+    )
 
 
 openCardExplorer : ChooseSetModel -> Database.Database -> ( Model, Cmd Msg )
-openCardExplorer initFlags database =
+openCardExplorer chooseSet database =
     ( CardExplorer
         { database = database
         , performanceDistributions = calculatePerformanceDistributions (Database.getAll database)
         , highlighted = Nothing
         , flipHighlighted = False
         , sortOrder = SortOrderDefault
+        , sets = chooseSet.sets
         }
     , Cmd.none
     )
@@ -196,9 +210,6 @@ update msg model =
     case model of
         ChooseSet mdl ->
             case msg of
-                StartDraft setCode database ->
-                    startDraft mdl database
-
                 OpenCardExplorer setCode database ->
                     openCardExplorer mdl database
 
@@ -281,11 +292,72 @@ update msg model =
                 IOGotSetData (Err e) ->
                     ( Error { error = "Error fetching remote set data (" ++ e ++ ")" }, Cmd.none )
 
+                ChangeDraftImportUrlField v ->
+                    let
+                        newImportDraft =
+                            case mdl.importDraft of
+                                EditingImportUrl _ ->
+                                    EditingImportUrl v
+
+                                _ ->
+                                    mdl.importDraft
+                    in
+                    ( ChooseSet { mdl | importDraft = newImportDraft }, Cmd.none )
+
+                AcknowledgeParseError ->
+                    ( ChooseSet { mdl | importDraft = EditingImportUrl "" }, Cmd.none )
+
+                SubmitImportDraft ->
+                    case mdl.importDraft of
+                        EditingImportUrl url ->
+                            case parseDraftIDFromPublicURL url of
+                                Ok draftID ->
+                                    ( ChooseSet
+                                        { mdl
+                                            | importDraft = LoadingDraft
+                                        }
+                                    , API.getDraft draftID IOGotDraft
+                                    )
+
+                                Err e ->
+                                    ( ChooseSet
+                                        { mdl
+                                            | importDraft = ImportDraftParseError e
+                                        }
+                                    , Cmd.none
+                                    )
+
+                        _ ->
+                            ( ChooseSet mdl, Cmd.none )
+
+                IOGotDraft (Ok draft) ->
+                    let
+                        setData =
+                            Dict.get draft.setCode mdl.sets
+                    in
+                    case setData of
+                        Just (HasLocalData db) ->
+                            startDraft mdl.sets draft db
+
+                        _ ->
+                            ( Error { error = "You must download the set data for the draft before you can open it. (" ++ draft.setCode ++ ")" }, Cmd.none )
+
+                IOGotDraft (Err e) ->
+                    ( Error { error = "Error fetching draft (" ++ e ++ ")" }, Cmd.none )
+
                 _ ->
                     ( ChooseSet mdl, Cmd.none )
 
         Ready mdl ->
             case msg of
+                BackToMenu ->
+                    ( ChooseSet
+                        { sets = mdl.sets
+                        , importDraft = EditingImportUrl ""
+                        }
+                    , Cmd.none
+                    )
+
                 Increment ->
                     ( Ready { mdl | draft = Zipper.moveRight mdl.draft }, Cmd.none )
 
@@ -359,15 +431,24 @@ update msg model =
                 IOGotDeleteSetData setCode ->
                     ( Ready mdl, Cmd.none )
 
-                PortReceiveDoesSetHaveLocalData _ ->
+                IOGotDraft _ ->
                     ( Ready mdl, Cmd.none )
 
-                StartDraft _ _ ->
+                PortReceiveDoesSetHaveLocalData _ ->
                     ( Ready mdl, Cmd.none )
 
                 OpenCardExplorer _ _ ->
                     ( Ready mdl, Cmd.none )
 
+                ChangeDraftImportUrlField _ ->
+                    ( Ready mdl, Cmd.none )
+
+                SubmitImportDraft ->
+                    ( Ready mdl, Cmd.none )
+
+                AcknowledgeParseError ->
+                    ( Ready mdl, Cmd.none )
+
         CardExplorer mdl ->
             case msg of
                 Highlight card ->
@@ -382,6 +463,14 @@ update msg model =
                 SetSortOrder sortOrder ->
                     ( CardExplorer { mdl | sortOrder = sortOrder }, Cmd.none )
 
+                BackToMenu ->
+                    ( ChooseSet
+                        { sets = mdl.sets
+                        , importDraft = EditingImportUrl ""
+                        }
+                    , Cmd.none
+                    )
+
                 _ ->
                     ( CardExplorer mdl, Cmd.none )
 
@@ -430,13 +519,22 @@ viewChooseSet model =
                 HasLocalData database ->
                     div [ class "flex gap-2" ]
                         [ Button.make "Explore" (OpenCardExplorer setCode database) |> Button.view
-                        , Button.make "View draft" (StartDraft setCode database) |> Button.view
                         , Button.make "Delete" (IODeleteSetData setCode) |> Button.view
                         ]
     in
-    div [ class "w-full h-full bg-slate-100 flex justify-center items-center" ]
-        [ div [ class "max-w-2xl max-h-2xl bg-slate-500 rounded-lg p-6 shadow-xl" ]
-            [ ul [ class "divide-y divide-slate-600" ]
+    div [ class "mt-2 flex flex-col gap-4 justify-center items-center overflow-y-scroll" ]
+        [ viewMobileWarning
+        , div
+            [ class "max-w-xl bg-slate-500 rounded-lg p-6 shadow-xl" ]
+            [ h2 [ class "text-3xl text-white font-medium" ] [ text "Drafter" ]
+            , p [ class "text-white font-medium" ] [ text "Explore sets and analyse your draft events." ]
+            , viewDraftURL model
+            ]
+        , div [ class "max-w-xl max-h-2xl bg-slate-500 rounded-lg p-6 shadow-xl" ]
+            [ h3 [ class "text-xl text-white font-medium" ] [ text "Set data" ]
+            , p [ class "text-white font-medium" ] [ text "Before analysing a draft, you need to download the data for the sets you want to analyse. After the first download, they are saved in your browser's local storage." ]
+            , p [ class "text-white font-medium" ] [ text "The performance data for each set is updated as new data is available in 17Lands. To make sure you have the latest data, you can delete the set and re-download it." ]
+            , ul [ class "divide-y divide-slate-600" ]
                 (List.map
                     (\s ->
                         li [ class "py-2 text-white flex gap-2 items-center space-between w-full" ] <|
@@ -455,10 +553,55 @@ viewChooseSet model =
         ]
 
 
+viewDraftURL : ChooseSetModel -> Html Msg
+viewDraftURL model =
+    case model.importDraft of
+        EditingImportUrl url ->
+            div [ class "mt-4" ]
+                [ div [ class "mb-2" ]
+                    [ label [ class "block text-sm font-medium text-white" ] [ text "Enter your 17Lands public draft URL:" ]
+                    , input
+                        [ type_ "text"
+                        , onInput ChangeDraftImportUrlField
+                        , value url
+                        , class "w-full"
+                        ]
+                        []
+                    ]
+                , Button.make "Import draft" SubmitImportDraft |> Button.view
+                ]
+
+        ImportDraftParseError e ->
+            div [ class "mt-4 text-white" ]
+                [ p [ class "mb-2 bg-red-300 text-black" ] [ text ("Could not extract draft ID from URL: " ++ e) ]
+                , p [ class "mb-2" ] [ text "Please make sure your URL looks like: https://www.17lands.com/draft/490bda1edc574d71a2768a7d7a415a25" ]
+                , Button.make "Try again" AcknowledgeParseError |> Button.view
+                ]
+
+        LoadingDraft ->
+            div [ class "mt-4" ]
+                [ text "Loading..." ]
+
+
+viewHeader : Html Msg
+viewHeader =
+    header [ class "bg-slate-600 p-4 w-full flex justify-between align-center col-span-12" ]
+        [ h1 [ class "text-3xl text-white font-medium" ] [ text "Drafter" ]
+        , Button.make "Home" BackToMenu |> Button.view
+        ]
+
+
+viewMobileWarning : Html Msg
+viewMobileWarning =
+    div [ class "bg-red-300 p-4 w-full md:hidden " ]
+        [ p [] [ text "This app is not optimized for mobile devices. Please use a desktop computer." ] ]
+
+
 viewReady : ReadyModel -> Html Msg
 viewReady model =
-    div [ class "grid grid-cols-12 gap-6 h-full bg-slate-100" ]
-        [ viewSidebar model
+    div [ class "grid grid-cols-12 gap-2 h-full grid-rows-[70px_1fr] bg-slate-100" ]
+        [ viewHeader
+        , viewSidebar model
         , viewDraft model
         , viewHighlightedCard model
         ]
@@ -466,8 +609,9 @@ viewReady model =
 
 viewCardExplorer : CardExplorerModel -> Html Msg
 viewCardExplorer model =
-    div [ class "grid grid-cols-12 gap-6 h-full bg-slate-100" ]
-        [ viewAllCards model
+    div [ class "grid grid-cols-12 gap-2 h-full bg-slate-100 grid-rows-[70px_1fr]" ]
+        [ viewHeader
+        , viewAllCards model
         , viewHighlightedCard model
         ]
 
@@ -1066,3 +1210,6 @@ port sendDeleteLocalData : Encode.Value -> Cmd msg
 
 
 port receiveDidDeleteLocalData : (String -> msg) -> Sub msg
+
+
+port sendSaveEventHistoryUrl : String -> Cmd msg

+ 12 - 1
tests/TestDatabase.elm

@@ -1,6 +1,6 @@
 module TestDatabase exposing (..)
 
-import Card exposing (CardData, CardDetails, CardPerformanceData, CardType(..), ManaColor(..), ManaCost(..), Power(..))
+import Card exposing (CardData, CardDetails, CardPerformanceData, CardType(..), ManaColor(..), ManaCost(..), Power(..), Rarity(..))
 import Database exposing (Database)
 import Expect
 import Fuzz exposing (Fuzzer)
@@ -55,6 +55,7 @@ fuzzCardDetails name =
         |> Fuzz.andMap (Fuzz.constant "{X}{R}")
         |> Fuzz.andMap (Fuzz.constant (Just [ X, Color Red ]))
         |> Fuzz.andMap Fuzz.string
+        |> Fuzz.andMap fuzzRarity
 
 
 fuzzCardType : Fuzzer CardType
@@ -80,6 +81,16 @@ fuzzPower =
         ]
 
 
+fuzzRarity : Fuzzer Rarity
+fuzzRarity =
+    Fuzz.oneOfValues
+        [ Common
+        , Uncommon
+        , Rare
+        , Mythic
+        ]
+
+
 fuzzManaColor : Fuzzer ManaColor
 fuzzManaColor =
     Fuzz.oneOfValues

+ 15 - 0
tests/TestDraftMeta.elm

@@ -0,0 +1,15 @@
+module TestDraftMeta exposing (..)
+
+import DraftMeta
+import Expect exposing (Expectation)
+import Fuzz exposing (Fuzzer, int, list, string)
+import Signals
+import Test exposing (..)
+
+
+suite : Test
+suite =
+    describe "DraftMeta"
+        [ test "parseDraftIDFromPublicURL" <|
+            \_ -> Expect.equal (DraftMeta.parseDraftIDFromPublicURL "https://www.17lands.com/draft/490bda1edc574d71a2768a7d7a415a25") (Ok "490bda1edc574d71a2768a7d7a415a25")
+        ]