pinpoint/client/src/client.gleam
2025-07-17 21:38:41 +10:00

746 lines
20 KiB
Gleam

import client/form_field.{type FormField, FormField}
import client/geofeed_form.{type GeofeedForm}
import client/geofeed_form_item.{type GeofeedFormItem, GeofeedFormItem}
import gleam/dict
import gleam/http/response.{type Response}
import gleam/int
import gleam/json
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import lustre
import lustre/attribute
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import lustre/element/svg
import lustre/event
import plinth/browser/document
import plinth/browser/element as plinth_element
import rsvp
import shared
import shared/cidr
import shared/city
import shared/country_code
import shared/geofeed
import shared/postcode
import shared/region_code
pub fn main() {
let geofeed =
document.query_selector("#" <> shared.model_element_id)
|> result.map(plinth_element.inner_text)
|> result.try(fn(json) {
json.parse(json, geofeed.decoder())
|> result.replace_error(Nil)
})
|> result.unwrap([])
let app = lustre.application(init, update, view)
let assert Ok(_) =
lustre.start(
app,
"#" <> shared.app_element_id,
geofeed_form.from_geofeed(geofeed),
)
Nil
}
type GeofeedSearchItem {
GeofeedSearchItem(
cidr: String,
country: String,
region: String,
city: String,
postcode: String,
)
}
type Model {
Model(
geofeed_form: GeofeedForm,
search_item: GeofeedSearchItem,
show_postcode: Bool,
saving: Bool,
errors: List(String),
)
}
fn init(geofeed_form: GeofeedForm) -> #(Model, Effect(Msg)) {
let search_item =
GeofeedSearchItem(cidr: "", country: "", region: "", city: "", postcode: "")
let model =
Model(
geofeed_form:,
search_item:,
show_postcode: False,
saving: False,
errors: [],
)
#(model, effect.none())
}
type Msg {
ServerSavedList(Result(Response(String), rsvp.Error))
UserSavedList
UserAddedItem(index: Option(Int))
UserMovedItemUp(index: Int)
UserMovedItemDown(index: Int)
UserDeletedItem(index: Int)
UserUpdatedItem(index: Int, item: GeofeedFormItem)
UserUpdatedSearchItem(search_item: GeofeedSearchItem)
UserSetShowPostcode(show_postcode: Bool)
}
fn prepend_form_field_errors(
errors errors: List(String),
form_field form_field: FormField(a),
index index: Int,
field_name field_name: String,
) -> List(String) {
case form_field.parsed_value {
Ok(_) -> errors
Error(_) -> [
"Invalid " <> field_name <> " at position " <> int.to_string(index + 1),
..errors
]
}
}
fn get_errors(model: Model) -> List(String) {
let errors =
list.index_fold(model.geofeed_form, [], fn(acc, item, index) {
acc
|> prepend_form_field_errors(
form_field: item.cidr,
index:,
field_name: "CIDR",
)
|> prepend_form_field_errors(
form_field: item.country,
index:,
field_name: "country code",
)
|> prepend_form_field_errors(
form_field: item.region,
index:,
field_name: "region code",
)
|> prepend_form_field_errors(
form_field: item.city,
index:,
field_name: "city",
)
|> prepend_form_field_errors(
form_field: item.postcode,
index:,
field_name: "postcode",
)
})
let errors =
list.index_fold(model.geofeed_form, #(errors, dict.new()), fn(acc, item, i) {
case item.cidr.parsed_value {
Ok(cidr) -> #(
case acc.1 |> dict.get(cidr) {
Ok(j) -> [
"Duplicate CIDR at position "
<> int.to_string(i + 1)
<> " (same as position "
<> int.to_string(j + 1)
<> ")",
..acc.0
]
Error(Nil) -> acc.0
},
acc.1 |> dict.insert(cidr, i),
)
Error(Nil) -> acc
}
}).0
list.reverse(errors)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
ServerSavedList(Ok(_)) -> #(
Model(..model, saving: False, errors: []),
effect.none(),
)
ServerSavedList(Error(_)) -> #(
Model(..model, saving: False, errors: ["Failed to save list"]),
effect.none(),
)
UserSavedList -> {
case get_errors(model) {
[] -> #(Model(..model, saving: True), {
let url = "/api/geofeed"
let body =
model.geofeed_form |> geofeed_form.to_geofeed |> geofeed.encode
rsvp.post(url, body, rsvp.expect_ok_response(ServerSavedList))
})
errors -> #(Model(..model, errors:), effect.none())
}
}
UserAddedItem(index:) -> {
let new_item =
GeofeedFormItem(
cidr: FormField(raw_value: "", parsed_value: Error(Nil)),
country: FormField(raw_value: "", parsed_value: Ok(None)),
region: FormField(raw_value: "", parsed_value: Ok(None)),
city: FormField(raw_value: "", parsed_value: Ok(None)),
postcode: FormField(raw_value: "", parsed_value: Ok(None)),
)
let geofeed_form = case index {
Some(index) ->
list.index_fold(model.geofeed_form, [], fn(acc, item, i) {
case i == index {
True -> [item, new_item, ..acc]
False -> [item, ..acc]
}
})
|> list.reverse
None -> list.append(model.geofeed_form, [new_item])
}
#(Model(..model, geofeed_form:), effect.none())
}
UserMovedItemUp(index:) -> {
let geofeed_form =
list.index_fold(model.geofeed_form, [], fn(acc, cur, i) {
case i == index, acc {
True, [] -> [cur, ..acc]
True, [prev, ..rest] -> [prev, cur, ..rest]
False, _ -> [cur, ..acc]
}
})
|> list.reverse
#(Model(..model, geofeed_form:), effect.none())
}
UserMovedItemDown(index:) -> {
let index = list.length(model.geofeed_form) - index - 1
let geofeed_form =
list.index_fold(list.reverse(model.geofeed_form), [], fn(acc, cur, i) {
case i == index, acc {
True, [] -> [cur, ..acc]
True, [prev, ..rest] -> [prev, cur, ..rest]
False, _ -> [cur, ..acc]
}
})
#(Model(..model, geofeed_form:), effect.none())
}
UserDeletedItem(index:) -> {
let geofeed_form =
list.index_fold(model.geofeed_form, [], fn(acc, cur, i) {
case i != index {
True -> [cur, ..acc]
False -> acc
}
})
|> list.reverse
#(Model(..model, geofeed_form:), effect.none())
}
UserUpdatedItem(index:, item:) -> {
let geofeed_form =
list.index_map(model.geofeed_form, fn(cur_item, item_index) {
case item_index == index {
True -> item
False -> cur_item
}
})
#(Model(..model, geofeed_form:), effect.none())
}
UserUpdatedSearchItem(search_item:) -> {
#(Model(..model, search_item:), effect.none())
}
UserSetShowPostcode(show_postcode:) -> {
#(Model(..model, show_postcode:), effect.none())
}
}
}
const default_font_size_style = #("font-size", "0.9em")
const text_align_center_style = #("text-align", "center")
const border_style = #("border", "1px solid #8f8f9d")
const border_radius_style = #("border-radius", "0.5em")
const cursor_pointer_style = #("cursor", "pointer")
fn form_button(
on_click_msg on_click_msg: Msg,
text text: String,
) -> Element(Msg) {
html.button(
[
event.on_click(on_click_msg),
attribute.styles([
#("padding", "0.4em 1em"),
shared.font_family_style,
default_font_size_style,
border_style,
border_radius_style,
cursor_pointer_style,
]),
],
[html.text(text)],
)
}
fn view(model: Model) -> Element(Msg) {
let Model(geofeed_form:, search_item:, show_postcode:, saving:, errors:) =
model
element.fragment([
html.h1(
[
attribute.styles([
#("margin", "0.5em 0 0 0"),
text_align_center_style,
shared.font_family_style,
]),
],
[html.text("Pinpoint 📌")],
),
html.h2(
[
attribute.styles([
#("margin", "0.3em 0 0 0"),
text_align_center_style,
shared.font_family_style,
]),
],
[html.text("Geofeed Editor")],
),
html.div(
[
attribute.styles([
#("margin", "0.5em 0 0 0"),
text_align_center_style,
shared.font_family_style,
#("font-size", "1em"),
]),
],
[
html.a(
[attribute.href("https://www.iso.org/obp/ui/#iso:pub:PUB500001:en")],
[html.text("View ISO Country/Region Codes")],
),
],
),
html.div(
[attribute.styles([#("display", "flex"), #("justify-content", "center")])],
[view_geofeed_form(geofeed_form:, search_item:, show_postcode:)],
),
element.fragment(
errors
|> list.map(fn(error) {
html.div(
[
attribute.styles([
shared.error_font_color_style,
shared.font_family_style,
default_font_size_style,
text_align_center_style,
#("margin-bottom", "1em"),
]),
],
[html.text(error)],
)
}),
),
html.div(
[
attribute.styles([
#("margin-bottom", "1em"),
#("display", "flex"),
#("justify-content", "center"),
#("gap", "0.6em"),
]),
],
[
form_button(on_click_msg: UserAddedItem(None), text: "Add"),
form_button(on_click_msg: UserSavedList, text: case saving {
True -> "Saving..."
False -> "Save"
}),
html.label(
[
attribute.styles([
#("padding", "0.4em 1em 0.4em 0.7em"),
shared.font_family_style,
default_font_size_style,
border_style,
border_radius_style,
]),
],
[
html.input([
attribute.type_("checkbox"),
event.on_check(UserSetShowPostcode),
]),
html.text("Postcode"),
],
),
],
),
])
}
fn input(
value value: String,
is_valid is_valid: Bool,
chars chars: Int,
set_max_length set_max_length: Bool,
placeholder placeholder: String,
on_input_handler on_input_handler: fn(String) -> Msg,
) {
html.input([
attribute.styles([
#("padding", "0.4em 0.6em"),
#("width", int.to_string(chars) <> "ch"),
shared.font_family_style,
default_font_size_style,
border_style,
border_radius_style,
..case is_valid {
True -> []
False -> [shared.error_font_color_style]
}
]),
attribute.placeholder(placeholder),
attribute.value(value),
event.on_input(on_input_handler),
..case set_max_length {
True -> [attribute.maxlength(chars)]
False -> []
}
])
}
fn form_field_input(
form_field form_field: FormField(a),
chars chars: Int,
set_max_length set_max_length: Bool,
placeholder placeholder: String,
on_input_handler on_input_handler: fn(String) -> Msg,
) {
input(
value: form_field.raw_value,
is_valid: result.is_ok(form_field.parsed_value),
chars:,
set_max_length:,
placeholder:,
on_input_handler:,
)
}
fn field_search(
value value: String,
form_field form_field: FormField(a),
) -> Bool {
value == ""
|| string.contains(
does: string.lowercase(form_field.raw_value),
contain: string.lowercase(value),
)
}
fn item_search(
search_item search_item: GeofeedSearchItem,
item item: GeofeedFormItem,
) -> Bool {
field_search(value: search_item.cidr, form_field: item.cidr)
&& field_search(value: search_item.country, form_field: item.country)
&& field_search(value: search_item.region, form_field: item.region)
&& field_search(value: search_item.city, form_field: item.city)
&& field_search(value: search_item.postcode, form_field: item.postcode)
}
fn view_geofeed_form(
geofeed_form geofeed_form: GeofeedForm,
search_item search_item: GeofeedSearchItem,
show_postcode show_postcode: Bool,
) -> Element(Msg) {
case geofeed_form {
[] ->
html.p([attribute.styles([text_align_center_style])], [
html.text("No items in your geofeed yet."),
])
_ -> {
let total = list.length(geofeed_form)
html.div([], [
html.div(
[
attribute.styles([
#("margin", "1em 0"),
#("display", "flex"),
#("gap", "0.6em"),
]),
],
[
input(
value: search_item.cidr,
is_valid: True,
chars: 43,
set_max_length: True,
placeholder: "CIDR",
on_input_handler: fn(value) {
UserUpdatedSearchItem(
GeofeedSearchItem(
..search_item,
cidr: string.lowercase(value),
),
)
},
),
input(
value: search_item.country,
is_valid: True,
chars: 2,
set_max_length: True,
placeholder: "CC",
on_input_handler: fn(value) {
UserUpdatedSearchItem(
GeofeedSearchItem(
..search_item,
country: string.uppercase(value),
),
)
},
),
input(
value: search_item.region,
is_valid: True,
chars: 6,
set_max_length: True,
placeholder: "Region",
on_input_handler: fn(value) {
UserUpdatedSearchItem(
GeofeedSearchItem(
..search_item,
region: string.uppercase(value),
),
)
},
),
input(
value: search_item.city,
is_valid: True,
chars: 24,
set_max_length: False,
placeholder: "City",
on_input_handler: fn(value) {
UserUpdatedSearchItem(
GeofeedSearchItem(..search_item, city: value),
)
},
),
..case show_postcode {
True -> [
input(
value: search_item.postcode,
is_valid: True,
chars: 10,
set_max_length: False,
placeholder: "Postcode",
on_input_handler: fn(value) {
UserUpdatedSearchItem(
GeofeedSearchItem(..search_item, postcode: value),
)
},
),
]
False -> []
}
],
),
case list.any(geofeed_form, item_search(search_item:, item: _)) {
True ->
html.ol(
[attribute.styles([#("margin", "0"), #("padding", "0")])],
list.index_map(geofeed_form, fn(item, index) {
html.li(
[
attribute.value(int.to_string(index + 1)),
..case item_search(search_item:, item:) {
True -> []
False -> [attribute.styles([#("display", "none")])]
}
],
[view_geofeed_item(item:, index:, total:, show_postcode:)],
)
}),
)
False ->
html.p([attribute.styles([text_align_center_style])], [
html.text("Search returned no results."),
])
},
])
}
}
}
fn item_button(
on_click_msg on_click_msg: Msg,
disabled disabled: Bool,
icon_path icon_path: String,
) -> Element(Msg) {
html.button(
[
event.on_click(on_click_msg),
attribute.disabled(disabled),
attribute.styles([
#("padding", "0 0.3em"),
#("display", "inline-flex"),
#("align-items", "center"),
shared.font_family_style,
#("font-size", "1.2em"),
border_style,
border_radius_style,
..case disabled {
True -> []
False -> [cursor_pointer_style]
}
]),
],
[
html.svg(
[
attribute.attribute("viewBox", "0 0 512 512"),
attribute.attribute("fill", "white"),
attribute.styles([#("width", "1em")]),
],
[svg.path([attribute.attribute("d", icon_path)])],
),
],
)
}
fn view_geofeed_item(
item item: GeofeedFormItem,
index index: Int,
total total: Int,
show_postcode show_postcode: Bool,
) -> Element(Msg) {
let item_buttons = [
item_button(
on_click_msg: UserMovedItemUp(index:),
disabled: index == 0,
icon_path: "m233.4 137.4c12.5-12.5 32.8-12.5 45.3 0l160 160c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0l-137.4-137.4-137.4 137.3c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3l160-160z",
),
item_button(
on_click_msg: UserMovedItemDown(index:),
disabled: index == total - 1,
icon_path: "m233.4 374.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-137.4 137.4-137.4-137.3c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z",
),
item_button(
on_click_msg: UserAddedItem(index: Some(index)),
disabled: False,
icon_path: "m288 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144-144 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z",
),
item_button(
on_click_msg: UserDeletedItem(index:),
disabled: False,
icon_path: "m406.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-105.3 105.4-105.4-105.3c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l105.4 105.3-105.3 105.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l105.3-105.4 105.4 105.3c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-105.4-105.3 105.3-105.4z",
),
]
html.div(
[
attribute.styles([
#("margin", "1em 0"),
#("display", "flex"),
#("gap", "0.6em"),
]),
],
[
form_field_input(
form_field: item.cidr,
chars: 43,
set_max_length: True,
placeholder: "CIDR",
on_input_handler: fn(value) {
UserUpdatedItem(
index,
GeofeedFormItem(
..item,
cidr: form_field.parse(string.lowercase(value), cidr.parse),
),
)
},
),
form_field_input(
form_field: item.country,
chars: 2,
set_max_length: True,
placeholder: "CC",
on_input_handler: fn(value) {
UserUpdatedItem(
index,
GeofeedFormItem(
..item,
country: form_field.parse_optional(
string.uppercase(value),
country_code.parse,
),
),
)
},
),
form_field_input(
form_field: item.region,
chars: 6,
set_max_length: True,
placeholder: "Region",
on_input_handler: fn(value) {
UserUpdatedItem(
index,
GeofeedFormItem(
..item,
region: form_field.parse_optional(
string.uppercase(value),
region_code.parse,
),
),
)
},
),
form_field_input(
form_field: item.city,
chars: 24,
set_max_length: False,
placeholder: "City",
on_input_handler: fn(value) {
UserUpdatedItem(
index,
GeofeedFormItem(
..item,
city: form_field.parse_optional(value, city.parse),
),
)
},
),
..case show_postcode {
True -> [
form_field_input(
form_field: item.postcode,
chars: 10,
set_max_length: False,
placeholder: "Postcode",
on_input_handler: fn(value) {
UserUpdatedItem(
index,
GeofeedFormItem(
..item,
postcode: form_field.parse_optional(value, postcode.parse),
),
)
},
),
..item_buttons
]
False -> item_buttons
}
],
)
}