Initial rebuild of position size calculator (#37)
parent
23fd7af9bc
commit
2e91972ebf
|
@ -5,8 +5,10 @@ edition = "2021"
|
|||
publish = false
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
|
||||
[features]
|
||||
hydration = [
|
||||
|
@ -77,6 +79,8 @@ features = [
|
|||
"LucideBug",
|
||||
"LucideCheck",
|
||||
"LucideCheckCircle",
|
||||
"LucideClipboardCheck",
|
||||
"LucideClipboardCopy",
|
||||
"LucideFlame",
|
||||
"LucideLink",
|
||||
"LucideList",
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
export function formatNumber(value, thousands, places, prefix, suffix) {
|
||||
const aval = Math.abs(value);
|
||||
const neg = value < 0;
|
||||
|
||||
const formatter = new Intl.NumberFormat(undefined, {
|
||||
style: "decimal",
|
||||
useGrouping: thousands ? "always" : false,
|
||||
minimumFractionDigits: places,
|
||||
});
|
||||
|
||||
const res =
|
||||
(neg ? "-" : "") + (prefix || "") + formatter.format(aval) + (suffix || "");
|
||||
|
||||
return res;
|
||||
}
|
|
@ -1,9 +1,26 @@
|
|||
pub mod analytics;
|
||||
pub mod blog;
|
||||
pub mod content;
|
||||
pub mod display;
|
||||
pub mod head;
|
||||
pub mod layout;
|
||||
pub mod render;
|
||||
pub mod seo;
|
||||
pub mod title;
|
||||
|
||||
pub mod blog {
|
||||
pub mod post_card;
|
||||
pub mod post_card_list;
|
||||
}
|
||||
|
||||
pub mod display {
|
||||
pub mod bar_chart;
|
||||
pub mod client_only;
|
||||
pub mod clipboard;
|
||||
pub mod tooltip;
|
||||
}
|
||||
|
||||
pub mod fields {
|
||||
pub mod currency;
|
||||
pub mod label;
|
||||
pub mod number;
|
||||
pub mod toggle;
|
||||
}
|
||||
|
|
|
@ -1,2 +0,0 @@
|
|||
pub mod post_card;
|
||||
pub mod post_card_list;
|
|
@ -1 +0,0 @@
|
|||
pub mod bar_chart;
|
|
@ -0,0 +1,29 @@
|
|||
use yew::{function_component, html, use_state, Children, Html, Properties};
|
||||
use yew_hooks::use_effect_once;
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ClientOnlyProps {
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(ClientOnly)]
|
||||
pub fn client_only(props: &ClientOnlyProps) -> Html {
|
||||
let loaded = use_state(|| false);
|
||||
|
||||
{
|
||||
let loaded = loaded.clone();
|
||||
use_effect_once(move || {
|
||||
loaded.set(true);
|
||||
|| {}
|
||||
})
|
||||
}
|
||||
|
||||
if !*loaded {
|
||||
html! {}
|
||||
} else {
|
||||
html! {
|
||||
<>{props.children.clone()}</>
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
use yew::{function_component, html, use_effect_with_deps, use_state, Callback, Html, Properties};
|
||||
use yew_hooks::use_clipboard;
|
||||
use yew_icons::{Icon, IconId};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CopyToClipboardProps {
|
||||
pub value: String,
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[function_component(CopyToClipboard)]
|
||||
pub fn copy_to_clipboard(props: &CopyToClipboardProps) -> Html {
|
||||
let copied = use_state(|| false);
|
||||
let clipboard = use_clipboard();
|
||||
|
||||
let onclick = {
|
||||
let value = props.value.clone();
|
||||
let format = props.format.clone();
|
||||
let copied = copied.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
clipboard.write(value.clone().into_bytes(), format.clone());
|
||||
copied.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let copied = copied.clone();
|
||||
use_effect_with_deps(
|
||||
move |_| {
|
||||
copied.set(false);
|
||||
},
|
||||
props.value.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
html! {
|
||||
<button type="button" title="Copy to clipboard" {onclick}>
|
||||
<Icon
|
||||
icon_id={
|
||||
if *copied {
|
||||
IconId::LucideClipboardCheck
|
||||
} else {
|
||||
IconId::LucideClipboardCopy
|
||||
}
|
||||
}
|
||||
width={"1em".to_string()}
|
||||
height={"1em".to_string()} />
|
||||
</button>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
use yew::{classes, function_component, html, use_state, Callback, Children, Html, Properties};
|
||||
use yew_icons::{Icon, IconId};
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum TooltipPosition {
|
||||
Top,
|
||||
Left,
|
||||
Bottom,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Default for TooltipPosition {
|
||||
fn default() -> Self {
|
||||
Self::Top
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct TooltipProps {
|
||||
#[prop_or_default]
|
||||
pub position: TooltipPosition,
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(Tooltip)]
|
||||
pub fn tooltip(props: &TooltipProps) -> Html {
|
||||
let open = use_state(|| false);
|
||||
|
||||
let onmouseover = {
|
||||
let open = open.clone();
|
||||
Callback::from(move |_| {
|
||||
open.set(true);
|
||||
})
|
||||
};
|
||||
|
||||
let onmouseout = {
|
||||
let open = open.clone();
|
||||
Callback::from(move |_| {
|
||||
open.set(false);
|
||||
})
|
||||
};
|
||||
|
||||
let popup = if *open {
|
||||
html! {
|
||||
<div class={classes!(
|
||||
"tooltip",
|
||||
match props.position {
|
||||
TooltipPosition::Top => "top",
|
||||
TooltipPosition::Left => "left",
|
||||
TooltipPosition::Right => "right",
|
||||
TooltipPosition::Bottom => "bottom"
|
||||
}
|
||||
)}>
|
||||
<div class="relative">
|
||||
{props.children.clone()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="relative inline-block cursor-pointer mx-2" {onmouseover} {onmouseout}>
|
||||
<Icon
|
||||
icon_id={IconId::HeroiconsSolidQuestionMarkCircle}
|
||||
width={"1em".to_string()}
|
||||
height={"1em".to_string()} />
|
||||
{popup}
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
use web_sys::HtmlSelectElement;
|
||||
use yew::{function_component, html, Callback, Html, Properties, TargetCast};
|
||||
|
||||
use crate::model::currency::{Currency, CURRENCIES};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct CurrencySelectProps {
|
||||
pub value: Currency,
|
||||
pub onchange: Callback<Currency>,
|
||||
}
|
||||
|
||||
#[function_component(CurrencySelect)]
|
||||
pub fn currency_select(props: &CurrencySelectProps) -> Html {
|
||||
let onchange = {
|
||||
let onchange = props.onchange.clone();
|
||||
|
||||
Callback::from(move |event: yew::Event| {
|
||||
onchange.emit(
|
||||
event
|
||||
.target_dyn_into::<HtmlSelectElement>()
|
||||
.expect("target")
|
||||
.value()
|
||||
.parse::<Currency>()
|
||||
.expect("currency"),
|
||||
);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<select {onchange}>
|
||||
{
|
||||
for CURRENCIES.iter().map(|currency| html! {
|
||||
<option value={currency.to_string()} selected={*currency == props.value}>
|
||||
{currency.to_string()}
|
||||
</option>
|
||||
})
|
||||
}
|
||||
</select>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
use yew::{classes, function_component, html, AttrValue, Children, Classes, Html, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct LabelProps {
|
||||
pub title: AttrValue,
|
||||
#[prop_or_default]
|
||||
pub class: Classes,
|
||||
#[prop_or_default]
|
||||
pub children: Children,
|
||||
}
|
||||
|
||||
#[function_component(Label)]
|
||||
pub fn label(props: &LabelProps) -> Html {
|
||||
let class = classes!("flex", "flex-col", "gap-2", props.class.clone());
|
||||
|
||||
html! {
|
||||
<div class={class}>
|
||||
<label class="text-sm font-medium">
|
||||
{props.title.clone()}
|
||||
</label>
|
||||
{props.children.clone()}
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
use wasm_bindgen::{prelude::wasm_bindgen, JsValue};
|
||||
use web_sys::FocusEvent;
|
||||
use yew::{
|
||||
classes, function_component, html, use_effect_with_deps, use_node_ref, use_state, AttrValue,
|
||||
Callback, Classes, Html, Properties, TargetCast,
|
||||
};
|
||||
use yew_hooks::use_timeout;
|
||||
use yew_icons::{Icon, IconId};
|
||||
|
||||
#[wasm_bindgen(module = "/public/format.js")]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(catch, js_name = formatNumber)]
|
||||
pub fn format_number(
|
||||
value: f64,
|
||||
thousands: bool,
|
||||
places: usize,
|
||||
prefix: Option<&str>,
|
||||
suffix: Option<&str>,
|
||||
) -> Result<String, JsValue>;
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct NumberProps {
|
||||
pub icon_left: Option<IconId>,
|
||||
pub icon_right: Option<IconId>,
|
||||
|
||||
pub icon_left_class: Option<Classes>,
|
||||
pub icon_right_class: Option<Classes>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub class: Classes,
|
||||
pub id: Option<AttrValue>,
|
||||
pub name: Option<AttrValue>,
|
||||
pub placeholder: Option<AttrValue>,
|
||||
|
||||
#[prop_or_default]
|
||||
pub value: f64,
|
||||
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
#[prop_or_default]
|
||||
pub thousands: bool,
|
||||
#[prop_or_default]
|
||||
pub places: usize,
|
||||
pub prefix: Option<AttrValue>,
|
||||
pub suffix: Option<AttrValue>,
|
||||
|
||||
pub onfocus: Option<Callback<FocusEvent>>,
|
||||
pub onblur: Option<Callback<FocusEvent>>,
|
||||
pub onchange: Option<Callback<Option<f64>>>,
|
||||
pub oninput: Option<Callback<Option<f64>>>,
|
||||
}
|
||||
|
||||
#[function_component(Number)]
|
||||
pub fn number(props: &NumberProps) -> Html {
|
||||
let mut classes = Classes::from("text-right");
|
||||
|
||||
let icon_left = if let Some(icon) = props.icon_left {
|
||||
classes.push("icon-left");
|
||||
Some(html! {
|
||||
<Icon
|
||||
icon_id={icon}
|
||||
class={classes!("group-focus-within:text-indigo-300",
|
||||
props.icon_left_class.clone())} />
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let icon_right = if let Some(icon) = props.icon_right {
|
||||
classes.push("icon-right");
|
||||
Some(html! {
|
||||
<Icon
|
||||
icon_id={icon}
|
||||
class={classes!("group-focus-within:text-indigo-300",
|
||||
props.icon_right_class.clone())} />
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
classes.extend(props.class.clone());
|
||||
|
||||
let value = props.value;
|
||||
|
||||
// Create a state variable that we will use to store the value that the user enters into the
|
||||
// input element. We use this so that we only need to validate input when the user focuses away
|
||||
// from the input (or presses <Enter>). The default value for this state is the initial value
|
||||
// of the number field, formatted for reasonable input.
|
||||
let input_value = {
|
||||
let places = props.places;
|
||||
use_state(move || {
|
||||
format_number(value, false, places, None, None).expect("format_numbert to work")
|
||||
})
|
||||
};
|
||||
|
||||
// We need to keep track of whether the field is focused, which we do with this state variable.
|
||||
// When the user focuses and unfocuses the control, we toggle this state variable.
|
||||
let focused = use_state(|| false);
|
||||
|
||||
// We also want to keep track of a reference to our <input> element, so that we can select the
|
||||
// text when the user focuses the input. Doing this in the event handler is not having the
|
||||
// desired effect without a small delay.
|
||||
let input_ref = use_node_ref();
|
||||
|
||||
// When the value passed to this field changes, we want to update our state.
|
||||
{
|
||||
let input_value = input_value.clone();
|
||||
let focused = focused.clone();
|
||||
use_effect_with_deps(
|
||||
move |(value, places)| {
|
||||
if !*focused {
|
||||
input_value.set(
|
||||
format_number(*value, false, *places, None, None)
|
||||
.expect("format_number to work"),
|
||||
);
|
||||
}
|
||||
},
|
||||
(value, props.places),
|
||||
);
|
||||
}
|
||||
|
||||
let timeout = {
|
||||
let focused = focused.clone();
|
||||
let input_ref = input_ref.clone();
|
||||
|
||||
use_timeout(
|
||||
move || {
|
||||
if *focused {
|
||||
input_ref
|
||||
.cast::<web_sys::HtmlInputElement>()
|
||||
.expect("<input> not attached to reference")
|
||||
.select();
|
||||
}
|
||||
},
|
||||
50,
|
||||
)
|
||||
};
|
||||
|
||||
let parse_input = {
|
||||
let places = props.places;
|
||||
let input_value = input_value.clone();
|
||||
let onchange = props.onchange.clone();
|
||||
|
||||
Callback::from(move |()| {
|
||||
let value = if let Ok(value) = (*input_value).parse::<f64>() {
|
||||
// Update the text value of the input to match our parsed number. We only format to
|
||||
// the desired number of places here, so we don't force the user to cursor around
|
||||
// formatting characters and thousand separators.
|
||||
input_value.set(
|
||||
format_number(value, false, places, None, None)
|
||||
.expect("Number formatting to work"),
|
||||
);
|
||||
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(onchange) = &onchange {
|
||||
onchange.emit(value);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let onfocus = {
|
||||
let timeout = timeout.clone();
|
||||
let focused = focused.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
focused.set(true);
|
||||
timeout.reset();
|
||||
})
|
||||
};
|
||||
|
||||
let onblur = {
|
||||
let focused = focused.clone();
|
||||
let parse_input = parse_input.clone();
|
||||
|
||||
Callback::from(move |_| {
|
||||
focused.set(false);
|
||||
parse_input.emit(());
|
||||
})
|
||||
};
|
||||
|
||||
let onchange = { Callback::from(move |_| parse_input.emit(())) };
|
||||
|
||||
let oninput = {
|
||||
let input_value = input_value.clone();
|
||||
let oninput = props.oninput.clone();
|
||||
|
||||
Callback::from(move |event: yew::InputEvent| {
|
||||
let Some(el) = event.target_dyn_into::<web_sys::HtmlInputElement>() else {
|
||||
log::error!("No <input> element found in event");
|
||||
return;
|
||||
};
|
||||
|
||||
let value = el.value();
|
||||
input_value.set(value.clone());
|
||||
|
||||
if let Some(oninput) = &oninput {
|
||||
oninput.emit(value.parse::<f64>().ok());
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// If the field is focused, then we want to allow the user to work with their own value, as a
|
||||
// string. if the field is not focused, then we want to render the number value using the
|
||||
// prefix, suffix, thousands separator and places.
|
||||
let rendered = if *focused {
|
||||
(*input_value).clone()
|
||||
} else {
|
||||
format_number(
|
||||
value,
|
||||
props.thousands,
|
||||
props.places,
|
||||
props.prefix.as_deref(),
|
||||
props.suffix.as_deref(),
|
||||
)
|
||||
.expect("format_number to work")
|
||||
};
|
||||
|
||||
let id = props.id.clone();
|
||||
let name = props.name.clone();
|
||||
let placeholder = props.placeholder.clone();
|
||||
|
||||
let input = html! {
|
||||
<input
|
||||
ref={input_ref}
|
||||
class={classes}
|
||||
type="text"
|
||||
id={id}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
disabled={props.disabled}
|
||||
value={rendered}
|
||||
{onfocus}
|
||||
{onblur}
|
||||
{onchange}
|
||||
{oninput} />
|
||||
};
|
||||
|
||||
if icon_left.is_some() || icon_right.is_some() {
|
||||
html! {
|
||||
<div class="input-icons group">
|
||||
{icon_left}
|
||||
{input}
|
||||
{icon_right}
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
input
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
use yew::{classes, function_component, html, AttrValue, Callback, Classes, Html, Properties};
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ToggleProps {
|
||||
#[prop_or_default]
|
||||
pub label: AttrValue,
|
||||
pub value: bool,
|
||||
#[prop_or_default]
|
||||
pub classes: Classes,
|
||||
#[prop_or_default]
|
||||
pub disabled: bool,
|
||||
pub onchange: Callback<bool>,
|
||||
}
|
||||
|
||||
#[function_component(Toggle)]
|
||||
pub fn toggle(props: &ToggleProps) -> Html {
|
||||
let onclick = {
|
||||
let onchange = props.onchange.clone();
|
||||
let value = props.value;
|
||||
let disabled = props.disabled;
|
||||
|
||||
Callback::from(move |_| {
|
||||
if disabled {
|
||||
return;
|
||||
}
|
||||
|
||||
onchange.emit(!value);
|
||||
})
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class={classes!("toggle",
|
||||
if props.value { "active" } else { "inactive" },
|
||||
if props.disabled { "disabled" } else { "" },
|
||||
props.classes.clone())}
|
||||
{onclick}>
|
||||
<div class="toggle-background" />
|
||||
<div class="toggle-inner" />
|
||||
</div>
|
||||
if !props.label.is_empty() {
|
||||
<div>
|
||||
{props.label.clone()}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
|
@ -43,8 +43,8 @@ pub fn footer(_: &FooterProps) -> Html {
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 md:gap-1 md:items-end">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-3">
|
||||
<div class="flex flex-col gap-4 lg:gap-1 lg:items-end">
|
||||
<div class="flex flex-col md:items-end lg:items-start lg:flex-row gap-4 lg:gap-3">
|
||||
<Link<Route> classes="hover:text-neutral-50" to={Route::Blog}>
|
||||
{"Latest Posts"}
|
||||
</Link<Route>>
|
||||
|
@ -65,6 +65,9 @@ pub fn footer(_: &FooterProps) -> Html {
|
|||
rel="noreferrer">
|
||||
{"Mastodon"}
|
||||
</a>
|
||||
<Link<Route> classes="hover:text-neutral-50" to={Route::PositionSize}>
|
||||
{"Position Size Calculator"}
|
||||
</Link<Route>>
|
||||
<Link<Route> classes="hover:text-neutral-50" to={Route::Analytics}>
|
||||
{"Analytics"}
|
||||
</Link<Route>>
|
||||
|
|
|
@ -6,8 +6,14 @@ use yew::{function_component, html, use_memo, Children, ContextProvider, Html, P
|
|||
macros::tags!("content/tags.yaml");
|
||||
|
||||
pub mod blog;
|
||||
pub mod currency;
|
||||
pub mod pages;
|
||||
|
||||
pub mod trading {
|
||||
pub mod account;
|
||||
pub mod position;
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct ProvideTagsProps {
|
||||
#[prop_or_default]
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
use std::{collections::HashMap, fmt::Display, str::FromStr};
|
||||
|
||||
use gloo::net::http::Request;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Hash, Serialize, Deserialize)]
|
||||
pub enum Currency {
|
||||
AUD,
|
||||
CAD,
|
||||
EUR,
|
||||
GBP,
|
||||
JPY,
|
||||
USD,
|
||||
}
|
||||
|
||||
impl Default for Currency {
|
||||
fn default() -> Self {
|
||||
Self::USD
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Currency {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
Currency::AUD => "AUD",
|
||||
Currency::CAD => "CAD",
|
||||
Currency::EUR => "EUR",
|
||||
Currency::GBP => "GBP",
|
||||
Currency::JPY => "JPY",
|
||||
Currency::USD => "USD",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Currency {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"AUD" => Ok(Currency::AUD),
|
||||
"CAD" => Ok(Currency::CAD),
|
||||
"EUR" => Ok(Currency::EUR),
|
||||
"GBP" => Ok(Currency::GBP),
|
||||
"JPY" => Ok(Currency::JPY),
|
||||
"USD" => Ok(Currency::USD),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Currency {
|
||||
pub fn symbol(&self) -> &'static str {
|
||||
match self {
|
||||
Currency::AUD => "$",
|
||||
Currency::CAD => "$",
|
||||
Currency::EUR => "€",
|
||||
Currency::GBP => "£",
|
||||
Currency::JPY => "¥",
|
||||
Currency::USD => "$",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static CURRENCIES: &[Currency] = &[
|
||||
Currency::AUD,
|
||||
Currency::CAD,
|
||||
Currency::EUR,
|
||||
Currency::GBP,
|
||||
Currency::JPY,
|
||||
Currency::USD,
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExchangeRates {
|
||||
/// The base currency
|
||||
pub base: Currency,
|
||||
/// The rates converting from `base` to the target currency
|
||||
pub rates: HashMap<Currency, f64>,
|
||||
}
|
||||
|
||||
impl Default for ExchangeRates {
|
||||
fn default() -> Self {
|
||||
Self::new(Currency::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl ExchangeRates {
|
||||
pub fn new(base: Currency) -> Self {
|
||||
let mut rates = HashMap::new();
|
||||
rates.insert(base, 1.0);
|
||||
Self { base, rates }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExchangeRateResult {
|
||||
rates: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
pub async fn get_exchange_rates(
|
||||
base: Currency,
|
||||
target: Option<Currency>,
|
||||
) -> Result<ExchangeRates, &'static str> {
|
||||
let symbols = target.as_ref().map(Currency::to_string).unwrap_or_else(|| {
|
||||
CURRENCIES
|
||||
.iter()
|
||||
.map(Currency::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
});
|
||||
|
||||
let res = Request::get(&format!(
|
||||
"https://api.exchangerate.host/latest?base={base}&symbols={symbols}"
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
log::error!("Failed to send request to api.exchangerate.host: {err:?}");
|
||||
"Failed to send request to api.exchangerate.host"
|
||||
})?
|
||||
.json::<ExchangeRateResult>()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
log::error!("Failed to parse response from api.exchangerate.host: {err:?}");
|
||||
"Failed to parse response from api.exchangerate.host"
|
||||
})?;
|
||||
|
||||
let mut rates = HashMap::new();
|
||||
for (symbol, rate) in res.rates {
|
||||
match symbol.parse() {
|
||||
Ok(currency) => {
|
||||
rates.insert(currency, rate);
|
||||
}
|
||||
|
||||
Err(err) => {
|
||||
log::error!("Failed to parse currency symbol '{symbol}' from api.exchangerate.host: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ExchangeRates { base, rates })
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
use std::rc::Rc;
|
||||
|
||||
use gloo::storage::{errors::StorageError, Storage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yew::Reducible;
|
||||
|
||||
use crate::model::currency::{Currency, ExchangeRates};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
pub places: usize,
|
||||
pub currency: Currency,
|
||||
pub exchange_rates: ExchangeRates,
|
||||
pub amount: f64,
|
||||
pub margin_risk: f64,
|
||||
pub position_risk: f64,
|
||||
}
|
||||
|
||||
impl Default for Account {
|
||||
fn default() -> Self {
|
||||
let currency = Currency::GBP;
|
||||
|
||||
Self {
|
||||
places: 4,
|
||||
currency,
|
||||
exchange_rates: ExchangeRates::new(currency),
|
||||
amount: 500.0,
|
||||
margin_risk: 0.01,
|
||||
position_risk: 0.01,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AccountAction {
|
||||
Load,
|
||||
SetPlaces { places: usize },
|
||||
SetCurrency { currency: Currency },
|
||||
SetExchangeRates { exchange_rates: ExchangeRates },
|
||||
SetAmount { amount: f64 },
|
||||
SetMarginRisk { risk: f64 },
|
||||
SetPositionRisk { risk: f64 },
|
||||
}
|
||||
|
||||
impl Reducible for Account {
|
||||
type Action = AccountAction;
|
||||
|
||||
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
|
||||
let account = match action {
|
||||
AccountAction::Load => Self::load(),
|
||||
|
||||
AccountAction::SetPlaces { places } => Self {
|
||||
places,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
AccountAction::SetCurrency { currency } => Self {
|
||||
currency,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
AccountAction::SetExchangeRates { exchange_rates } => Self {
|
||||
exchange_rates,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
AccountAction::SetAmount { amount } => Self {
|
||||
amount,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
AccountAction::SetMarginRisk { risk } => Self {
|
||||
margin_risk: risk,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
AccountAction::SetPositionRisk { risk } => Self {
|
||||
position_risk: risk,
|
||||
..(*self).clone()
|
||||
},
|
||||
};
|
||||
|
||||
account.save();
|
||||
account.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Account {
|
||||
pub fn load() -> Self {
|
||||
match gloo::storage::LocalStorage::get("trading.account") {
|
||||
Ok(stored) => stored,
|
||||
Err(err) => match err {
|
||||
StorageError::KeyNotFound(_) => {
|
||||
log::info!("No stored trading account found, using defaults");
|
||||
Self::default()
|
||||
}
|
||||
|
||||
_ => {
|
||||
log::error!("Failed to retrieve trading account from local storage: {err:?}");
|
||||
Self::default()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
if let Err(err) = gloo::storage::LocalStorage::set("trading.account", self) {
|
||||
log::error!("Failed to store trading account in local storage: {err:?}");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,360 @@
|
|||
use std::{fmt::Display, rc::Rc, str::FromStr};
|
||||
|
||||
use yew::Reducible;
|
||||
|
||||
use crate::model::currency::Currency;
|
||||
|
||||
use super::account::Account;
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq)]
|
||||
pub enum Direction {
|
||||
Buy,
|
||||
Sell,
|
||||
}
|
||||
|
||||
impl Display for Direction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Buy => write!(f, "Buy"),
|
||||
Self::Sell => write!(f, "Sell"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Direction {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"Buy" => Ok(Self::Buy),
|
||||
"Sell" => Ok(Self::Sell),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Position {
|
||||
pub position_currency: Currency,
|
||||
pub quote_currency: Currency,
|
||||
pub conversion: f64,
|
||||
pub open_price: f64,
|
||||
pub quantity: Option<f64>,
|
||||
pub direction: Direction,
|
||||
pub margin: f64,
|
||||
pub take_profit: Option<f64>,
|
||||
pub stop_loss: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for Position {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
position_currency: Currency::GBP,
|
||||
quote_currency: Currency::GBP,
|
||||
conversion: 1.0,
|
||||
open_price: 0.0,
|
||||
quantity: None,
|
||||
direction: Direction::Buy,
|
||||
margin: 0.05,
|
||||
take_profit: None,
|
||||
stop_loss: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PositionAction {
|
||||
SetPositionCurrency { currency: Currency },
|
||||
SetQuoteCurrency { currency: Currency },
|
||||
SetConversion { conversion: f64 },
|
||||
SetOpenPrice { price: f64 },
|
||||
SetQuantity { quantity: Option<f64> },
|
||||
SetDirection { direction: Direction },
|
||||
SetMargin { margin: f64 },
|
||||
SetTakeProfit { price: Option<f64> },
|
||||
SetStopLoss { price: Option<f64> },
|
||||
}
|
||||
|
||||
impl Reducible for Position {
|
||||
type Action = PositionAction;
|
||||
|
||||
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
|
||||
match action {
|
||||
PositionAction::SetPositionCurrency { currency } => Self {
|
||||
position_currency: currency,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetQuoteCurrency { currency } => Self {
|
||||
quote_currency: currency,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetConversion { conversion } => Self {
|
||||
conversion,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetOpenPrice { price } => Self {
|
||||
open_price: price,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetQuantity { quantity } => Self {
|
||||
quantity,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetDirection { direction } => Self {
|
||||
direction,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetMargin { margin } => Self {
|
||||
margin,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetTakeProfit { price } => Self {
|
||||
take_profit: price,
|
||||
..(*self).clone()
|
||||
},
|
||||
|
||||
PositionAction::SetStopLoss { price } => Self {
|
||||
stop_loss: price,
|
||||
..(*self).clone()
|
||||
},
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PositionSize {
|
||||
/// Funds available under margin risk (in account currency)
|
||||
pub available: f64,
|
||||
/// Funds available under margin risk (in position currency)
|
||||
pub available_position: f64,
|
||||
/// Funds available under margin risk (in quote currency)
|
||||
pub available_quote: f64,
|
||||
/// Margin available under margin risk (in account currency)
|
||||
pub margin: f64,
|
||||
/// Margin available (in position currency)
|
||||
pub margin_position: f64,
|
||||
/// margin available (in quote currency)
|
||||
pub margin_quote: f64,
|
||||
/// Quantity affordable at position price (in units)
|
||||
pub affordable: f64,
|
||||
/// Optional actual position size margin risk
|
||||
pub actual: Option<ActualPositionSize>,
|
||||
}
|
||||
|
||||
impl PositionSize {
|
||||
pub fn compute(account: &Account, position: &Position) -> Self {
|
||||
let p_rate = account
|
||||
.exchange_rates
|
||||
.rates
|
||||
.get(&position.position_currency)
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let q_rate = position.conversion;
|
||||
let available = account.amount * account.margin_risk;
|
||||
let available_position = available * p_rate;
|
||||
let available_quote = available_position * q_rate;
|
||||
|
||||
let margin = available
|
||||
/ if position.margin == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
position.margin
|
||||
};
|
||||
|
||||
let margin_position = margin * p_rate;
|
||||
let margin_quote = margin_position * q_rate;
|
||||
|
||||
let affordable = if position.open_price == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
margin_quote / position.open_price
|
||||
};
|
||||
|
||||
Self {
|
||||
available,
|
||||
available_position,
|
||||
available_quote,
|
||||
margin,
|
||||
margin_position,
|
||||
margin_quote,
|
||||
affordable,
|
||||
actual: position.quantity.map(|quantity| {
|
||||
ActualPositionSize::compute(account, position, quantity, p_rate, q_rate)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ActualPositionSize {
|
||||
/// The actual quote cost (in quote currency)
|
||||
pub cost_quote: f64,
|
||||
/// The actual position cost (in position currency)
|
||||
pub cost_position: f64,
|
||||
/// The actual position cost (in account currency)
|
||||
pub cost: f64,
|
||||
/// The account margin required (percent)
|
||||
pub margin: f64,
|
||||
}
|
||||
|
||||
impl ActualPositionSize {
|
||||
pub fn compute(
|
||||
account: &Account,
|
||||
position: &Position,
|
||||
quantity: f64,
|
||||
q_rate: f64,
|
||||
p_rate: f64,
|
||||
) -> Self {
|
||||
let cost_quote = quantity * position.open_price * position.margin;
|
||||
let cost_position = cost_quote / q_rate;
|
||||
let cost = cost_position / p_rate;
|
||||
let margin = cost / account.amount;
|
||||
|
||||
Self {
|
||||
cost_quote,
|
||||
cost_position,
|
||||
cost,
|
||||
margin,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StopLoss {
|
||||
/// Funds available under position risk (in account currency)
|
||||
pub available: f64,
|
||||
/// Funds available under position risk (in position currency)
|
||||
pub available_position: f64,
|
||||
/// Funds available under position risk (in quote currency)
|
||||
pub available_quote: f64,
|
||||
/// Specified position size
|
||||
pub quantity: f64,
|
||||
/// Required stop-loss distance
|
||||
pub distance: f64,
|
||||
/// Optional actual stop-loss assessment
|
||||
pub actual: Option<ActualStopLoss>,
|
||||
}
|
||||
|
||||
impl StopLoss {
|
||||
pub fn compute(account: &Account, position: &Position, quantity: f64) -> Self {
|
||||
let p_rate = account
|
||||
.exchange_rates
|
||||
.rates
|
||||
.get(&position.position_currency)
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let q_rate = position.conversion;
|
||||
let available = account.amount * account.position_risk;
|
||||
let available_position = available * p_rate;
|
||||
let available_quote = available_position * q_rate;
|
||||
let distance = if quantity == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
available_quote / quantity
|
||||
};
|
||||
|
||||
Self {
|
||||
available,
|
||||
available_position,
|
||||
available_quote,
|
||||
quantity,
|
||||
distance,
|
||||
actual: position.stop_loss.map(|stop_loss| {
|
||||
ActualStopLoss::compute(account, position, quantity, p_rate, q_rate, stop_loss)
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ActualStopLoss {
|
||||
/// The actual stop-loss distance (in position currency)
|
||||
pub distance: f64,
|
||||
/// The possible loss
|
||||
pub loss: f64,
|
||||
/// The actual position risk (percent)
|
||||
pub risk: f64,
|
||||
}
|
||||
|
||||
impl ActualStopLoss {
|
||||
pub fn compute(
|
||||
account: &Account,
|
||||
position: &Position,
|
||||
quantity: f64,
|
||||
p_rate: f64,
|
||||
q_rate: f64,
|
||||
stop_loss: f64,
|
||||
) -> Self {
|
||||
let distance = match position.direction {
|
||||
Direction::Buy => position.open_price - stop_loss,
|
||||
Direction::Sell => stop_loss - position.open_price,
|
||||
};
|
||||
|
||||
let loss = (distance * quantity) / (p_rate * q_rate);
|
||||
let risk = loss / account.amount;
|
||||
|
||||
Self {
|
||||
distance,
|
||||
loss,
|
||||
risk,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct StopLossQuantity {
|
||||
/// Funds available under position risk (in account currency)
|
||||
pub available: f64,
|
||||
/// Funds available under position risk (in position currency)
|
||||
pub available_position: f64,
|
||||
/// Funds available under position risk (in quote currency)
|
||||
pub available_quote: f64,
|
||||
/// Computed stop loss distance (in position currency)
|
||||
pub distance: f64,
|
||||
/// Amount that can be bought at the given stop loss (in units)
|
||||
pub affordable: f64,
|
||||
/// Required margin for that amount (in account currency)
|
||||
pub margin: f64,
|
||||
}
|
||||
|
||||
impl StopLossQuantity {
|
||||
pub fn compute(account: &Account, position: &Position) -> Self {
|
||||
let distance = if let Some(stop_loss) = position.stop_loss {
|
||||
match position.direction {
|
||||
Direction::Buy => position.open_price - stop_loss,
|
||||
Direction::Sell => stop_loss - position.open_price,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let p_rate = account
|
||||
.exchange_rates
|
||||
.rates
|
||||
.get(&position.position_currency)
|
||||
.copied()
|
||||
.unwrap_or(1.0);
|
||||
let q_rate = position.conversion;
|
||||
let available = account.amount * account.position_risk;
|
||||
let available_position = available * p_rate;
|
||||
let available_quote = available_position * q_rate;
|
||||
let affordable = available_quote / distance;
|
||||
let margin = (affordable * position.open_price * position.margin) / (p_rate * q_rate);
|
||||
|
||||
Self {
|
||||
available,
|
||||
available_position,
|
||||
available_quote,
|
||||
distance,
|
||||
affordable,
|
||||
margin,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,6 +13,10 @@ mod analytics {
|
|||
pub mod dashboard;
|
||||
}
|
||||
|
||||
mod trading {
|
||||
pub mod position_size;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Sequence, Routable)]
|
||||
pub enum Route {
|
||||
#[at("/")]
|
||||
|
@ -27,6 +31,8 @@ pub enum Route {
|
|||
Disclaimer,
|
||||
#[at("/analytics")]
|
||||
Analytics,
|
||||
#[at("/trading/position-size")]
|
||||
PositionSize,
|
||||
#[not_found]
|
||||
#[at("/404")]
|
||||
NotFound,
|
||||
|
@ -46,6 +52,7 @@ impl Route {
|
|||
Self::Disclaimer => html! { <disclaimer::Page /> },
|
||||
Self::NotFound => html! { <not_found::Page /> },
|
||||
Self::Analytics => html! { <analytics::dashboard::Page /> },
|
||||
Self::PositionSize => html! { <trading::position_size::Page /> },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ use crate::{
|
|||
api::{get_month_views, PageViewsMonth, PageViewsMonthResult},
|
||||
auth::{AuthTokenContext, WithAuth},
|
||||
},
|
||||
components::display::bar_chart::BarChart,
|
||||
components::{display::bar_chart::BarChart, seo::WebPageSeo, title::Title},
|
||||
pages::Route,
|
||||
};
|
||||
|
||||
fn month_view_chart(
|
||||
|
@ -242,8 +243,17 @@ fn dashboard_content() -> Html {
|
|||
#[function_component(Page)]
|
||||
pub fn page() -> Html {
|
||||
html! {
|
||||
<WithAuth>
|
||||
<DashboardContent />
|
||||
</WithAuth>
|
||||
<>
|
||||
<Title title={"Analytics Dashboard"} />
|
||||
<WebPageSeo
|
||||
route={Route::Analytics}
|
||||
title={"Analytics Dashboard"}
|
||||
excerpt={Some("Analytics Dashboard")}
|
||||
index={false}
|
||||
follow={false} />
|
||||
<WithAuth>
|
||||
<DashboardContent />
|
||||
</WithAuth>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
195
style/main.css
195
style/main.css
|
@ -1,5 +1,10 @@
|
|||
@import "tailwind.css";
|
||||
|
||||
:root {
|
||||
--toggle-size: 2rem;
|
||||
--primary-color: #13304e;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
body {
|
||||
@apply bg-white text-neutral-800;
|
||||
|
@ -7,31 +12,171 @@
|
|||
}
|
||||
|
||||
.button {
|
||||
@apply inline-flex items-center justify-center border border-transparent;
|
||||
@apply inline-flex items-center justify-center;
|
||||
@apply border border-transparent rounded-md;
|
||||
|
||||
@apply px-4 py-2;
|
||||
@apply rounded-md shadow-sm text-sm text-gray-300 bg-primary;
|
||||
@apply disabled:bg-slate-300 dark:disabled:bg-gray-600 dark:disabled:text-gray-400;
|
||||
@apply hover:text-white dark:disabled:hover:text-gray-400;
|
||||
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
||||
@apply transition-colors;
|
||||
@apply text-sm;
|
||||
|
||||
@apply text-neutral-200 hover:text-neutral-100;
|
||||
@apply disabled:text-gray-400 dark:disabled:text-gray-800;
|
||||
|
||||
@apply bg-primary;
|
||||
@apply disabled:bg-gray-200 dark:disabled:bg-gray-600;
|
||||
@apply active:bg-primary-light;
|
||||
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary;
|
||||
@apply transition;
|
||||
|
||||
> svg {
|
||||
@apply mr-1;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"] {
|
||||
@apply border-primary rounded-md;
|
||||
@apply text-neutral-800 placeholder:text-neutral-300;
|
||||
@apply dark:bg-zinc-800 dark:text-neutral-200 dark:placeholder:text-neutral-700;
|
||||
@apply focus:outline-none focus:ring focus:ring-offset-2 focus:ring-opacity-50 focus:ring-slate-400;
|
||||
@apply disabled:border-transparent;
|
||||
|
||||
&:disabled {
|
||||
@apply text-neutral-500 dark:text-neutral-500;
|
||||
@apply dark:bg-zinc-900;
|
||||
@apply text-neutral-800 placeholder:text-neutral-300;
|
||||
@apply dark:text-neutral-200 placeholder:text-neutral-500;
|
||||
@apply disabled:text-gray-400 dark:disabled:text-gray-800;
|
||||
|
||||
@apply bg-white dark:bg-zinc-800;
|
||||
@apply disabled:bg-gray-200 dark:disabled:bg-gray-600;
|
||||
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50 focus:ring-primary;
|
||||
@apply transition;
|
||||
}
|
||||
|
||||
.input-icons {
|
||||
@apply relative;
|
||||
|
||||
svg:first-child,
|
||||
svg:last-child {
|
||||
@apply pointer-events-none absolute top-1/2 -mt-2.5 text-gray-300 dark:text-gray-500;
|
||||
}
|
||||
|
||||
svg:first-child {
|
||||
@apply left-3;
|
||||
}
|
||||
|
||||
svg:last-child {
|
||||
@apply right-3;
|
||||
}
|
||||
|
||||
select,
|
||||