Skip to content

Instantly share code, notes, and snippets.

@giacomocavalieri
Created July 2, 2024 17:02
Show Gist options
  • Save giacomocavalieri/b09a64c005c693766e1aa92bbf19f3c2 to your computer and use it in GitHub Desktop.
Save giacomocavalieri/b09a64c005c693766e1aa92bbf19f3c2 to your computer and use it in GitHub Desktop.
pokemon_team
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([])
}
})
}
@giacomocavalieri
Copy link
Author

This has the main drag and drop logic and doesn't include the other auxiliary modules like seq or load!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment