Files
lootalot-client/src/Main.elm

595 lines
20 KiB
Elm

module Main exposing (..)
import Browser
import Browser.Navigation as Nav
import Url
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Svg.Attributes
import Url.Parser as P exposing (Parser, (</>), oneOf, s)
import Set exposing (Set)
import Json.Encode as E
import Api exposing (Player, Loot, Wealth, Item, Claim, Claims)
import Modes exposing (ViewMode)
-- Main
main : Program () Model Msg
main =
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlChange = UrlChanged
, onUrlRequest = LinkClicked
}
-- Model
type alias Selection = Set Int
type alias State =
{ navKey : Nav.Key
, route : Route
, error : String
, menuOpen : Bool
, selection : Maybe Selection
, activeMode : Maybe ViewMode
}
type alias Model =
{ state : State
, player: Player
, claims : Claims
, notification : Maybe String
, loot: Maybe Loot
, groupLoot : Maybe Loot
, merchantItems : Maybe Loot
}
init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
let
route = case P.parse routeParser url of
Just r -> r
Nothing -> PlayerChest
in
( Model
(State key route "" False Nothing Nothing)
Api.blankPlayer
[]
Nothing
Nothing
Nothing
Nothing
, fetchInitialData 0)
fetchInitialData : Int -> Cmd Msg
fetchInitialData playerId =
Cmd.batch
[ initPlayer playerId
, Cmd.map ApiMsg <| Api.fetchLoot Api.OfShop
, Cmd.map ApiMsg <| Api.fetchLoot Api.OfGroup
]
initPlayer id =
Cmd.batch
[ Cmd.map ApiMsg <| Api.fetchPlayer id
, Cmd.map ApiMsg <| Api.fetchLoot (Api.OfPlayer id)
, Cmd.map ApiMsg <| Api.fetchClaims id
]
-- UPDATE
type Msg
= LinkClicked Browser.UrlRequest
| UrlChanged Url.Url
| ApiMsg Api.Msg
| PlayerChanged Int
| LootViewItemSwitched Int
| ModeSwitched (Maybe ViewMode)
| ConfirmAction
| UndoLastAction
| ClearNotification
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked urlRequest ->
case urlRequest of
Browser.Internal url ->
( model, Nav.pushUrl model.state.navKey (Url.toString url) )
Browser.External href ->
( setError ("External request '" ++ href ++ "'") model
, Cmd.none )
UrlChanged url ->
let
route = P.parse routeParser url
state = model.state
in
case route of
Just page ->
{ model | state = { state | route = page }}
|> update (case page of
-- Directly enter add mode on NewLoot view
NewLoot -> ModeSwitched (Just Modes.Add)
other -> ModeSwitched Nothing
)
Nothing ->
( setError "Invalid route" model, Cmd.none )
PlayerChanged newId ->
( { model | player = Api.blankPlayer }, initPlayer newId )
ApiMsg apiMsg -> case apiMsg of
Api.GotActionResult response ->
case response of
Ok result ->
let
updates = Maybe.withDefault [] result.updates
notification = result.notification
errors = Maybe.withDefault "" result.errors
in
List.foldl applyUpdate model updates
|> setNotification notification
|> setError errors
|> update (ModeSwitched Nothing)
Err r -> (setError (Debug.toString r) model, Cmd.none)
Api.GotPlayer result ->
case result of
Ok player ->
( { model | player = player }
, Cmd.none
)
Err error ->
( setError ("Fetching player... " ++ Debug.toString error) model
, Cmd.none
)
Api.GotClaims id result ->
case result of
Ok claims -> ( { model | claims = List.filter (\c -> c.player_id == id) claims}, Cmd.none )
Err error -> ( setError ("Fetching claims..." ++ Debug.toString error) model, Cmd.none)
Api.GotLoot dest result ->
case result of
Ok loot ->
( case dest of
Api.OfPlayer _ -> { model | loot = Just loot}
Api.OfGroup -> { model | groupLoot = Just loot}
Api.OfShop -> { model | merchantItems = Just loot}
, Cmd.none
)
Err error ->
( setError ("Fetching loot... " ++ Debug.toString error) model
, Cmd.none
)
LootViewItemSwitched id ->
let
state = model.state
in
( { model | state =
{ state | selection = Debug.log "new selection"
<| switchSelectionState id state.selection }}
, Cmd.none )
ModeSwitched newMode ->
let
state = model.state
in
( { model | state =
{ state | activeMode = newMode
, selection =
case newMode of
Nothing ->
Nothing
Just Modes.Grab -> -- Currently claimed object are initially selected
Just ( Set.fromList <| List.map (\c -> c.loot_id) model.claims)
Just others ->
Just Set.empty
}}
, Cmd.none )
ConfirmAction ->
case model.state.activeMode of
Nothing ->
update (ModeSwitched Nothing) model
Just mode ->
let items = targetItemsFor mode model
|> List.filter (itemInSelection model.state.selection)
in
( model
, Cmd.map ApiMsg
<| Api.sendRequest
mode
(String.fromInt model.player.id)
items
)
UndoLastAction ->
(model, Cmd.map ApiMsg <| Api.undoLastAction model.player.id)
ClearNotification ->
( { model | notification = Nothing }, Cmd.none )
setNotification : Maybe String -> Model -> Model
setNotification notification model =
{ model | notification = notification }
targetItemsFor : ViewMode -> Model -> List Item
targetItemsFor mode model =
case mode of
Modes.Add -> []
Modes.Buy -> Maybe.withDefault [] model.merchantItems
Modes.Sell ->Maybe.withDefault [] model.loot
Modes.Grab -> Maybe.withDefault [] model.groupLoot
-- DbUpdates always refer to the active player's loot
applyUpdate : Api.Update -> Model -> Model
applyUpdate u model =
case u of
Api.ItemRemoved item -> { model | loot = Just
<| List.filter (\i -> i.id /= item.id)
<| Maybe.withDefault [] model.loot }
Api.ItemAdded item -> { model | loot = Just
<| item :: Maybe.withDefault [] model.loot }
Api.WealthUpdated diff ->
let
player = model.player
wealth = player.wealth
in
{ model | player = { player | wealth =
(Wealth
(wealth.cp + diff.cp)
(wealth.sp + diff.sp)
(wealth.gp + diff.gp)
(wealth.pp + diff.pp)
)}}
Api.ClaimAdded _ -> model
Api.ClaimRemoved _ -> model
-- ERRORS
setError : String -> Model -> Model
setError error model =
let
state = model.state
in
{ model | state =
{ state | error = error }}
-- STATE Utils
switchSelectionState : Int -> Maybe Selection -> Maybe Selection
switchSelectionState id selection =
case selection of
Just s ->
Just <| case Set.member id s of
True -> Set.remove id s
False -> Set.insert id s
Nothing -> Debug.log "ignore switchSelectionState" Nothing
-- SUBSCRIPTIONS
--
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.none
---
-- VIEWS
---
actionButton msg t icon color =
button [ class <| "button level-item is-" ++ color
, onClick msg ]
[ span [ class "icon" ] [ i [ Svg.Attributes.class <| "fas fa-" ++ icon ] [] ]
, p [] [text t]
]
controlsWhenModeActive : ViewMode -> List (Html Msg)
controlsWhenModeActive mode =
[ actionButton (ConfirmAction) "Valider" "check" "primary"
, actionButton (ModeSwitched Nothing) "Annuler" "times" "danger"
]
controlsWhenRoute : Route -> List (Html Msg)
controlsWhenRoute route =
case route of
PlayerChest -> [actionButton (ModeSwitched (Just Modes.Sell)) "Vendre" "coins" "danger"]
GroupLoot -> [actionButton (ModeSwitched (Just Modes.Grab)) "Demander" "praying-hands" "primary"]
Merchant -> [actionButton (ModeSwitched (Just Modes.Buy)) "Acheter" "coins" "success"]
NewLoot -> [actionButton (ModeSwitched (Just Modes.Add)) "Nouveau loot" "plus" "primary"]
view : Model -> Browser.Document Msg
view model =
let
-- What do we show inside the chest ?
(header, shownLoot) =
case model.state.route of
PlayerChest ->
("Mon coffre", Maybe.withDefault [] model.loot)
GroupLoot ->
("Coffre de groupe", Maybe.withDefault [] model.groupLoot)
Merchant ->
("Marchand", Maybe.withDefault [] model.merchantItems)
NewLoot ->
("Nouveau trésor :)", [] )
{- Dynamic renderers for ViewMode
Header controls are inserted in the PlayerBar
and rowControls to the right side of every item rows
-}
(headerControls, rowControls) =
case model.state.activeMode of
Just mode ->
( controlsWhenModeActive mode, Just (rowControlsForMode mode isSelected))
Nothing -> -- Buttons to enter mode
( actionButton UndoLastAction "Annuler action" "backspace" "danger"
:: controlsWhenRoute model.state.route
-- Claim controls for Group chest
, case model.state.route of
GroupLoot -> Just (renderIfClaimed <| itemInClaims model.claims)
_ -> Nothing
)
-- TODO: should we extract the Maybe conversion
-- and represent cannotSelect with Nothing ??
isSelected =
itemInSelection model.state.selection
in
{ title = "Loot-a-lot in ELM"
, body =
[ viewHeaderBar model
, viewPlayerBar model.player model.notification headerControls
, article [class "section container"]
[ p [class "heading"] [text header]
, viewSearchBar
, viewChest isSelected rowControls shownLoot
]
, hr [] []
, section [class "container"] [viewDebugSection model]
]
}
viewNotification : Maybe String -> Html Msg
viewNotification notification =
case notification of
Just t -> div [ class "notification is-success is-marginless"]
[ button [class "delete", onClick ClearNotification ] []
, text t ]
Nothing -> text ""
-- LOOT Views
itemInSelection : Maybe Selection -> Item -> Bool
itemInSelection selection item =
Maybe.map (Set.member item.id) selection
|> Maybe.withDefault False
itemInClaims : List Claim -> Item -> Bool
itemInClaims claims item =
List.any (\c -> c.loot_id == item.id) claims
renderIfClaimed : (Item -> Bool) -> Item -> Html Msg
renderIfClaimed isClaimed item =
case isClaimed item of
True -> renderIcon "fas fa-praying-hands" "1x"
False -> text ""
viewChest : (Item -> Bool) -> Maybe (Item -> Html Msg) -> Loot -> Html Msg
viewChest isSelected rowControls items =
table [ class "table is-fullwidth is-hoverable"]
[ thead [ class "table-header" ]
[ th [] [ text "Nom" ] ]
, tbody [] <| List.map (viewItemTableRow isSelected rowControls) items
]
-- Renders controls for a specific mode
rowControlsForMode : ViewMode -> (Item -> Bool) -> Item -> Html Msg
rowControlsForMode mode isSelected item =
let
itemInfo = case mode of
Modes.Buy -> p [class "level-item"] [ text (String.fromInt item.base_price ++ "po")]
Modes.Sell -> p [class "level-item"] [ text (String.fromFloat (toFloat item.base_price / 2) ++ "po")]
Modes.Grab -> p [class "level-item"] [ text "Grab" ]
Modes.Add -> p [class "level-item"] [ text "New !" ]
in
div [ class "level-right" ]
<| itemInfo
:: if Modes.canSelectIn mode then
[input [ class "checkbox level-item"
, type_ "checkbox"
, checked <| isSelected item
, onCheck (\v -> LootViewItemSwitched item.id)
] [] ]
else
[]
viewItemTableRow : (Item -> Bool) -> Maybe (Item -> Html Msg) -> Item -> Html Msg
viewItemTableRow isSelected rowControls item =
tr [ classList [ ("is-selected", isSelected item) ] ]
[ td []
[ label [ class "level checkbox" ]
<| div [ class "level-left" ]
[ p [class "level-item"] [ text item.name ]]
:: case rowControls of
Just render -> List.singleton (render item)
Nothing -> []
]
]
-- DEBUG SECTION
viewDebugSection : Model -> Html Msg
viewDebugSection model =
div [class "panel is-danger"]
[ p [class "panel-heading"] [text "Debug"]
, debugSwitchPlayers
, p [class "panel-block has-text-danger"] [text model.state.error]
, p [class "panel-block"] [text ("Route : " ++ Debug.toString model.state.route)]
, p [class "panel-block"] [text ("Active Mode : " ++ Debug.toString model.state.activeMode)]
, p [class "panel-block"] [text ("Selection : " ++ Debug.toString model.state.selection)]
, p [class "panel-block"] [text ("Claims : " ++ Debug.toString model.claims)]
, p [] debugSandbox
]
stackedIcon name =
span [class "icon is-medium"]
[ span [ class "fa-stack" ]
[ i [ class "fas fa-circle fa-stack-2x" ] []
, i [ class (name ++ " fa-inverse fa-stack-1x") ] []
, text ""
]
]
debugSandbox =
[ stackedIcon "fas fa-coins"
, stackedIcon "fab fa-d-and-d"
, stackedIcon "fas fa-praying-hands"
, stackedIcon "fas fa-gem"
, stackedIcon "fas fa-pen"
, stackedIcon "fas fa-percentage"
, stackedIcon "fas fa-store-alt"
, stackedIcon "fas fa-cart-plus"
, stackedIcon "fas fa-angry"
, stackedIcon "fas fa-plus"
, stackedIcon "fas fa-tools"
, stackedIcon "fas fa-search"
]
debugSwitchPlayers : Html Msg
debugSwitchPlayers =
div [ class "panel-tabs" ]
[ a [ onClick (PlayerChanged 0) ] [text "Groupe"]
, a [ onClick (PlayerChanged 1) ] [text "Lomion"]
, a [ onClick (PlayerChanged 2) ] [text "Fefi"]
]
renderIcon name size =
span [ class <| "icon is-medium"]
[ i [ class <| name ++ " fa-" ++ size] [] ]
-- HEADER SECTION
viewHeaderBar : Model -> Html Msg
viewHeaderBar model =
nav [ class "navbar container", class "is-info" ]
[ div [ class "navbar-brand" ]
[ a [ class "navbar-item", href "/"]
[ renderIcon "fab fa-d-and-d" "2x"
, span [] [ text model.player.name ]
]
, a [class "navbar-burger is-active"]
[ span [attribute "aria-hidden" "true"] []
, span [attribute "aria-hidden" "true"] []
, span [attribute "aria-hidden" "true"] []
]
]
, div [ class "navbar-menu is-active" ]
[ div [class "navbar-end"]
[ a [class "navbar-item", href "/marchand"]
[ renderIcon "fas fa-store-alt" "1x"
, span [] [text "Marchand"]
]
, a
[ class "navbar-item"
, href (if model.player.id == 0
then
"/nouveau-tresor"
else
"/coffre")
]
[ renderIcon "fas fa-gem" "1x"
, span [] [text (if model.player.id == 0 then "Nouveau loot" else "Coffre de groupe")]
]
]
]
]
-- PLAYER BAR
viewPlayerBar : Player -> Maybe String -> List (Html Msg)-> Html Msg
viewPlayerBar player notification actionControls =
section [ class "level container is-mobile box" ]
[ div [class "level-left"]
([div [ class "level-item" ]
[ span [ class "icon is-large" ]
[ i [ class "fas fa-2x fa-piggy-bank" ] [] ]]
]
++ (viewWealth player.wealth)
++ (if player.debt > 0 then
[div [class "level-item"]
[p [class "heading is-size-4 has-text-danger"]
[text ("Dette : " ++ (String.fromInt player.debt) ++ "po")]
]]
else
[]
)
)
, viewNotification notification
, div [class "level-right"] actionControls
]
viewWealth : Wealth -> List (Html Msg)
viewWealth wealth =
[ showWealthField "pp" <| String.fromInt wealth.pp
, showWealthField "gp" <| String.padLeft 2 '0' <| String.fromInt wealth.gp
, showWealthField "sp" <| String.fromInt wealth.sp
, showWealthField "cp" <| String.fromInt wealth.cp
]
showWealthField : String -> String -> Html Msg
showWealthField name value =
div [ class "level-item" ]
[ p [class "has-text-right"] [ strong [ class "heading is-marginless"] [text name]
, span [ class <| "is-size-4" ] [ text value ]
]
]
-- Search Bar
viewSearchBar : Html Msg
viewSearchBar =
input [class "input"] []
---
-- ROUTES
---
type Route
= PlayerChest
| Merchant
| GroupLoot
| NewLoot
routeParser : Parser (Route -> a) a
routeParser =
oneOf
[ P.map GroupLoot (P.s "coffre")
, P.map PlayerChest P.top
, P.map Merchant (P.s "marchand")
, P.map NewLoot (P.s "nouveau-tresor")
]