Created
July 2, 2024 17:02
-
-
Save giacomocavalieri/b09a64c005c693766e1aa92bbf19f3c2 to your computer and use it in GitHub Desktop.
pokemon_team
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import decode.{type Decoder} | |
import extra | |
import gleam/dynamic | |
import gleam/json | |
import gleam/list | |
import gleam/string | |
import lustre | |
import lustre/attribute.{type Attribute} | |
import lustre/effect.{type Effect} | |
import lustre/element.{type Element} | |
import lustre/element/html | |
import lustre/event | |
import lustre_http as http | |
import seq.{type Seq} | |
import store | |
pub fn main() { | |
let app = lustre.application(init, update, view) | |
let assert Ok(_) = lustre.start(app, "#app", Nil) | |
} | |
// MODEL ----------------------------------------------------------------------- | |
pub type Model { | |
Model(team: Seq(Slot), new_name: String, dragging: Dragging) | |
} | |
pub type Slot { | |
Slot(name: String, sprite: String, types: List(Type)) | |
} | |
pub type Type { | |
Normal | |
Fighting | |
Flying | |
Poison | |
Ground | |
Rock | |
Bug | |
Ghost | |
Steel | |
Fire | |
Water | |
Grass | |
Electric | |
Psychic | |
Ice | |
Dragon | |
Dark | |
Fairy | |
} | |
pub type Dragging { | |
Dragging(from: Int) | |
DraggingOverSlot(from: Int, over: Int) | |
DraggingOverNewArea(from: Int) | |
NotDragging | |
} | |
fn init(_) -> #(Model, Effect(Msg)) { | |
let initial_model = Model(seq.from_list([]), "", NotDragging) | |
#(initial_model, load_team()) | |
} | |
fn type_from_string(string: String) -> Result(Type, Nil) { | |
case string { | |
"normal" -> Ok(Normal) | |
"fighting" -> Ok(Fighting) | |
"flying" -> Ok(Flying) | |
"poison" -> Ok(Poison) | |
"ground" -> Ok(Ground) | |
"rock" -> Ok(Rock) | |
"bug" -> Ok(Bug) | |
"ghost" -> Ok(Ghost) | |
"steel" -> Ok(Steel) | |
"fire" -> Ok(Fire) | |
"water" -> Ok(Water) | |
"grass" -> Ok(Grass) | |
"electric" -> Ok(Electric) | |
"psychic" -> Ok(Psychic) | |
"ice" -> Ok(Ice) | |
"dragon" -> Ok(Dragon) | |
"dark" -> Ok(Dark) | |
"fairy" -> Ok(Fairy) | |
_ -> Error(Nil) | |
} | |
} | |
fn type_to_string(type_: Type) -> String { | |
case type_ { | |
Normal -> "normal" | |
Fighting -> "fighting" | |
Flying -> "flying" | |
Poison -> "poison" | |
Ground -> "ground" | |
Rock -> "rock" | |
Bug -> "bug" | |
Ghost -> "ghost" | |
Steel -> "steel" | |
Fire -> "fire" | |
Water -> "water" | |
Grass -> "grass" | |
Electric -> "electric" | |
Psychic -> "psychic" | |
Ice -> "ice" | |
Dragon -> "dragon" | |
Dark -> "dark" | |
Fairy -> "fairy" | |
} | |
} | |
// UPDATE ---------------------------------------------------------------------- | |
pub type Msg { | |
UserTypedName(name: String) | |
UserSubmittedNewPokemon | |
ServerSentPokemon(response: Result(Slot, http.HttpError)) | |
LocalStoreLoadedTeam(Seq(Slot)) | |
DragMsg(DragMsg) | |
} | |
pub type DragMsg { | |
UserStartedDraggingSlot(index: Int) | |
UserEnteredSlotArea(index: Int) | |
UserLeftSlotArea(index: Int) | |
UserEnteredNewArea | |
UserLeftNewArea | |
UserDroppedSlot | |
} | |
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { | |
case msg { | |
LocalStoreLoadedTeam(team) -> #(Model(..model, team: team), effect.none()) | |
ServerSentPokemon(Error(_)) -> #(model, effect.none()) | |
ServerSentPokemon(Ok(slot)) -> { | |
let new_team = model.team |> seq.push(slot) | |
let new_model = Model(..model, team: new_team) | |
#(new_model, save_team(new_team)) | |
} | |
UserTypedName(name) -> #(Model(..model, new_name: name), effect.none()) | |
UserSubmittedNewPokemon -> #( | |
Model(..model, new_name: ""), | |
request_pokemon(model.new_name), | |
) | |
DragMsg(UserDroppedSlot as msg) -> { | |
let new_dragging = update_dragging(model.dragging, msg) | |
let new_team = case model.dragging { | |
DraggingOverSlot(from, to) -> model.team |> seq.switch(from, to) | |
DraggingOverNewArea(from) -> model.team |> seq.remove_at(from) | |
Dragging(_) | NotDragging -> model.team | |
} | |
let new_model = Model(..model, team: new_team, dragging: new_dragging) | |
#(new_model, save_team(new_team)) | |
} | |
DragMsg(msg) -> #( | |
Model(..model, dragging: update_dragging(model.dragging, msg)), | |
effect.none(), | |
) | |
} | |
} | |
fn update_dragging(dragging: Dragging, msg: DragMsg) -> Dragging { | |
case msg { | |
UserDroppedSlot -> NotDragging | |
UserStartedDraggingSlot(slot) -> Dragging(slot) | |
UserEnteredNewArea -> | |
case dragging { | |
NotDragging -> dragging | |
DraggingOverSlot(from, _) | Dragging(from) | DraggingOverNewArea(from) -> | |
DraggingOverNewArea(from) | |
} | |
UserLeftNewArea -> | |
case dragging { | |
DraggingOverNewArea(slot) -> Dragging(slot) | |
_ -> dragging | |
} | |
UserEnteredSlotArea(new_slot) -> | |
case dragging { | |
NotDragging -> dragging | |
DraggingOverSlot(from, _) | Dragging(from) | DraggingOverNewArea(from) -> | |
DraggingOverSlot(from, new_slot) | |
} | |
UserLeftSlotArea(left_slot) -> | |
case dragging { | |
DraggingOverSlot(start, slot) if slot == left_slot -> Dragging(start) | |
_ -> dragging | |
} | |
} | |
} | |
// EFFECTS --------------------------------------------------------------------- | |
fn request_pokemon(name: String) { | |
let url = | |
"https://pokeapi.co/api/v2/pokemon/" <> string.lowercase(string.trim(name)) | |
let decode_pokeapi_pokemon = decode.from(pokeapi_pokemon_decoder(), _) | |
http.get(url, http.expect_json(decode_pokeapi_pokemon, ServerSentPokemon)) | |
} | |
fn save_team(team: Seq(Slot)) -> Effect(Msg) { | |
store.save(encode_team(team), at: "team") | |
} | |
fn load_team() -> Effect(Msg) { | |
store.load("team", team_decoder(), LocalStoreLoadedTeam) | |
} | |
// ENCODERS AND DECODERS ------------------------------------------------------- | |
fn encode_team(team: Seq(Slot)) -> json.Json { | |
let encode_type = fn(type_) { type_to_string(type_) |> json.string } | |
use slot <- json.array(seq.to_list(team)) | |
let Slot(name: name, sprite: sprite, types: types) = slot | |
json.object([ | |
#("name", json.string(name)), | |
#("sprite", json.string(sprite)), | |
#("types", json.array(types, encode_type)), | |
]) | |
} | |
fn team_decoder() -> Decoder(Seq(Slot)) { | |
decode.list( | |
decode.into({ | |
use name <- decode.parameter | |
use sprite <- decode.parameter | |
use types <- decode.parameter | |
Slot(name: name, sprite: sprite, types: types) | |
}) | |
|> decode.field("name", decode.string) | |
|> decode.field("sprite", decode.string) | |
|> decode.field("types", decode.list(type_decoder())), | |
) | |
|> decode.map(seq.from_list) | |
} | |
fn pokeapi_pokemon_decoder() { | |
let pokeapi_type_decoder = decode.at(["type", "name"], type_decoder()) | |
decode.into({ | |
use name <- decode.parameter | |
use sprite <- decode.parameter | |
use types <- decode.parameter | |
Slot(name, sprite, types) | |
}) | |
|> decode.field("name", decode.string) | |
|> decode.field("sprites", decode.at(["front_default"], decode.string)) | |
|> decode.field("types", decode.list(pokeapi_type_decoder)) | |
} | |
fn type_decoder() { | |
use value <- decode.then(decode.string) | |
case type_from_string(value) { | |
Ok(type_) -> decode.into(type_) | |
Error(_) -> decode.fail("Not a type: " <> value) | |
} | |
} | |
// VIEW ------------------------------------------------------------------------ | |
fn view(model: Model) -> Element(Msg) { | |
let slots = | |
seq.index_map(model.team, fn(slot, i) { | |
html.li([], [view_slot(slot, model.dragging, i)]) | |
}) | |
let list_items = | |
list.append(slots, [ | |
html.li([], [view_new_area(model.dragging, model.new_name)]), | |
]) | |
html.main([], [extra.css(), html.ul([], list_items)]) | |
} | |
fn is_active_drop_target(position: Int, dragging: Dragging) -> Bool { | |
case dragging { | |
DraggingOverSlot(_, over) -> over == position | |
_ -> False | |
} | |
} | |
fn is_dragging(dragging: Dragging) -> Bool { | |
case dragging { | |
Dragging(_) | DraggingOverSlot(_, _) | DraggingOverNewArea(_) -> True | |
NotDragging -> False | |
} | |
} | |
fn view_slot(slot: Slot, dragging: Dragging, position: Int) -> Element(Msg) { | |
let dragging_classes = [ | |
#("active-drop-target", position |> is_active_drop_target(dragging)), | |
#("stop-children-pointer-events", dragging |> is_dragging), | |
] | |
let slot_attributes = [ | |
attribute.class("slot"), | |
attribute.classes(dragging_classes), | |
attribute.attribute("draggable", "true"), | |
on_drag_start(DragMsg(UserStartedDraggingSlot(position))), | |
on_drag_enter(DragMsg(UserEnteredSlotArea(position))), | |
on_drag_leave(DragMsg(UserLeftSlotArea(position))), | |
on_drag_end(DragMsg(UserDroppedSlot)), | |
prevent("dragover"), | |
attribute.style([#("background-color", types_to_color(slot.types))]), | |
] | |
html.div(slot_attributes, [ | |
html.img([ | |
attribute.class("sprite"), | |
attribute.attribute("draggable", "false"), | |
prevent("dragover"), | |
attribute.src(slot.sprite), | |
]), | |
html.h2([], [element.text(slot.name)]), | |
]) | |
} | |
fn is_dragging_over_new_area(dragging: Dragging) -> Bool { | |
case dragging { | |
DraggingOverNewArea(_) -> True | |
_ -> False | |
} | |
} | |
fn view_new_area(dragging: Dragging, new_name: String) { | |
let dragging_classes = [ | |
#("active-drop-target", dragging |> is_dragging_over_new_area), | |
#("ready-to-delete", dragging |> is_dragging), | |
] | |
let content = case dragging { | |
NotDragging -> | |
html.input([ | |
attribute.autofocus(True), | |
event.on_input(UserTypedName), | |
on_enter(UserSubmittedNewPokemon), | |
attribute.placeholder("+ add"), | |
attribute.value(new_name), | |
]) | |
DraggingOverSlot(_, _) | Dragging(_) | DraggingOverNewArea(_) -> | |
element.text("- drop here to remove") | |
} | |
html.div( | |
[ | |
attribute.class("new-area"), | |
attribute.classes(dragging_classes), | |
event.on_submit(UserSubmittedNewPokemon), | |
on_drag_enter(DragMsg(UserEnteredNewArea)), | |
on_drag_leave(DragMsg(UserLeftNewArea)), | |
prevent("dragover"), | |
], | |
[content], | |
) | |
} | |
fn types_to_color(types: List(Type)) -> String { | |
case types { | |
[] -> "gray" | |
[Normal, ..] -> "#9FA19F" | |
[Fighting, ..] -> "#FF8000" | |
[Flying, ..] -> "#81B9EF" | |
[Poison, ..] -> "#9141CB" | |
[Ground, ..] -> "#915121" | |
[Rock, ..] -> "#AFA981" | |
[Bug, ..] -> "#91A119" | |
[Ghost, ..] -> "#704170" | |
[Steel, ..] -> "#60A1B8" | |
[Fire, ..] -> "#E62829" | |
[Water, ..] -> "#2980EF" | |
[Grass, ..] -> "#3FA129" | |
[Electric, ..] -> "#FAC000" | |
[Psychic, ..] -> "#EF4179" | |
[Ice, ..] -> "#3DCEF3" | |
[Dragon, ..] -> "#5060E1" | |
[Dark, ..] -> "#624D4E" | |
[Fairy, ..] -> "#ECBEBE" | |
} | |
} | |
// EXTRA EVENTS ---------------------------------------------------------------- | |
fn on_drag_start(msg: a) -> Attribute(a) { | |
event.on("dragstart", fn(_) { Ok(msg) }) | |
} | |
fn on_drag_enter(msg: a) -> Attribute(a) { | |
event.on("dragenter", fn(_) { Ok(msg) }) | |
} | |
fn prevent(event: String) -> Attribute(a) { | |
event.on(event, fn(event) { | |
event.prevent_default(event) | |
Error([]) | |
}) | |
} | |
fn on_drag_leave(msg: a) -> Attribute(a) { | |
event.on("dragleave", fn(_) { Ok(msg) }) | |
} | |
fn on_drag_end(msg: a) -> Attribute(a) { | |
event.on("dragend", fn(event) { | |
event.prevent_default(event) | |
Ok(msg) | |
}) | |
} | |
fn on_enter(msg: a) -> Attribute(a) { | |
event.on("keydown", fn(event) { | |
case dynamic.field("key", dynamic.string)(event) { | |
Ok("Enter") -> Ok(msg) | |
Ok(_) | Error(_) -> Error([]) | |
} | |
}) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This has the main drag and drop logic and doesn't include the other auxiliary modules like
seq
orload
!