feat: like button
This commit is contained in:
parent
e5853268de
commit
22367f45f2
@ -19,9 +19,7 @@ pub fn Attempt(#[prop(into)] date: Signal<DateTime<Utc>>, #[prop(into)] attempt:
|
||||
};
|
||||
|
||||
let text_color = match attempt.get() {
|
||||
Some(models::Attempt::Flash) => "text-cyan-500",
|
||||
Some(models::Attempt::Send) => "text-teal-500",
|
||||
Some(models::Attempt::Attempt) => "text-pink-500",
|
||||
Some(attempt) => attempt.gradient().class_text(),
|
||||
None => "",
|
||||
};
|
||||
|
||||
|
@ -19,34 +19,40 @@ pub fn Button(
|
||||
) -> impl IntoView {
|
||||
let margin = "mx-2 my-1 sm:mx-5 sm:my-2.5";
|
||||
|
||||
let icon_view = icon.get().map(|i| {
|
||||
let icon_view = i.into_view();
|
||||
let mut classes = "self-center".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(margin);
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_text());
|
||||
let icon_view = move || {
|
||||
icon.get().map(|i| {
|
||||
let icon_view = i.into_view();
|
||||
let mut classes = "self-center".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(margin);
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_text());
|
||||
|
||||
view! { <div class=classes>{icon_view}</div> }
|
||||
});
|
||||
view! { <div class=classes>{icon_view}</div> }
|
||||
})
|
||||
};
|
||||
|
||||
let separator = (icon.read().is_some() && text.read().is_some()).then(|| {
|
||||
let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_from());
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_to());
|
||||
let separator = move || {
|
||||
(icon.read().is_some() && text.read().is_some()).then(|| {
|
||||
let mut classes = "w-0.5 bg-linear-to-br min-w-0.5".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_from());
|
||||
classes.push(' ');
|
||||
classes.push_str(color.class_to());
|
||||
|
||||
view! { <div class=classes /> }
|
||||
});
|
||||
view! { <div class=classes /> }
|
||||
})
|
||||
};
|
||||
|
||||
let text_view = text.get().map(|text| {
|
||||
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(margin);
|
||||
let text_view = move || {
|
||||
text.get().map(|text| {
|
||||
let mut classes = "self-center uppercase w-full text-sm sm:text-base md:text-lg font-thin".to_string();
|
||||
classes.push(' ');
|
||||
classes.push_str(margin);
|
||||
|
||||
view! { <div class=classes>{text}</div> }
|
||||
});
|
||||
view! { <div class=classes>{text}</div> }
|
||||
})
|
||||
};
|
||||
|
||||
let class = move || {
|
||||
let mut classes = vec![];
|
||||
|
@ -7,6 +7,7 @@ pub enum Icon {
|
||||
WrenchSolid,
|
||||
ForwardSolid,
|
||||
Check,
|
||||
Heart,
|
||||
HeartOutline,
|
||||
ArrowPath,
|
||||
PaperAirplaneSolid,
|
||||
@ -26,6 +27,7 @@ impl Icon {
|
||||
Icon::WrenchSolid => view! { <WrenchSolid /> }.into_any(),
|
||||
Icon::ForwardSolid => view! { <ForwardSolid /> }.into_any(),
|
||||
Icon::Check => view! { <Check /> }.into_any(),
|
||||
Icon::Heart => view! { <Heart /> }.into_any(),
|
||||
Icon::HeartOutline => view! { <HeartOutline /> }.into_any(),
|
||||
Icon::ArrowPath => view! { <ArrowPath /> }.into_any(),
|
||||
Icon::PaperAirplaneSolid => view! { <PaperAirplaneSolid /> }.into_any(),
|
||||
@ -123,6 +125,20 @@ pub fn Check() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Heart() -> impl IntoView {
|
||||
view! {
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-6"
|
||||
>
|
||||
<path d="m11.645 20.91-.007-.003-.022-.012a15.247 15.247 0 0 1-.383-.218 25.18 25.18 0 0 1-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0 1 12 5.052 5.5 5.5 0 0 1 16.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 0 1-4.244 3.17 15.247 15.247 0 0 1-.383.219l-.022.012-.007.004-.003.001a.752.752 0 0 1-.704 0l-.003-.001Z" />
|
||||
</svg>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn HeartOutline() -> impl IntoView {
|
||||
view! {
|
||||
|
@ -22,12 +22,14 @@ pub fn OutlinedBox(children: Children, color: Gradient, #[prop(optional)] highli
|
||||
let mut c = "py-1.5 rounded-md".to_string();
|
||||
if highlight() {
|
||||
let bg = match color {
|
||||
Gradient::PinkOrange => "bg-pink-900",
|
||||
Gradient::PinkOrange => "bg-rose-900",
|
||||
Gradient::CyanBlue => "bg-cyan-800",
|
||||
Gradient::TealLime => "bg-teal-700",
|
||||
Gradient::PurplePink => "bg-purple-900",
|
||||
Gradient::TealLime => "bg-emerald-700",
|
||||
Gradient::PurplePink => "bg-fuchsia-950",
|
||||
Gradient::PurpleBlue => "bg-purple-900",
|
||||
Gradient::Orange => "bg-orange-900",
|
||||
Gradient::Pink => "bg-pink-900",
|
||||
Gradient::PinkRed => "bg-red-900",
|
||||
};
|
||||
|
||||
c.push(' ');
|
||||
|
@ -7,9 +7,11 @@ pub enum Gradient {
|
||||
PurplePink,
|
||||
#[default]
|
||||
Orange,
|
||||
Pink,
|
||||
PinkRed,
|
||||
}
|
||||
impl Gradient {
|
||||
pub fn class_from(&self) -> &str {
|
||||
pub fn class_from(&self) -> &'static str {
|
||||
match self {
|
||||
Gradient::PinkOrange => "from-pink-500",
|
||||
Gradient::CyanBlue => "from-cyan-500",
|
||||
@ -17,10 +19,12 @@ impl Gradient {
|
||||
Gradient::PurplePink => "from-purple-500",
|
||||
Gradient::PurpleBlue => "from-purple-600",
|
||||
Gradient::Orange => "from-orange-400",
|
||||
Gradient::Pink => "from-pink-400",
|
||||
Gradient::PinkRed => "from-pink-400",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn class_to(&self) -> &str {
|
||||
pub fn class_to(&self) -> &'static str {
|
||||
match self {
|
||||
Gradient::PinkOrange => "to-orange-400",
|
||||
Gradient::CyanBlue => "to-blue-500",
|
||||
@ -28,17 +32,21 @@ impl Gradient {
|
||||
Gradient::PurplePink => "to-pink-500",
|
||||
Gradient::PurpleBlue => "to-blue-500",
|
||||
Gradient::Orange => "to-orange-500",
|
||||
Gradient::Pink => "to-pink-500",
|
||||
Gradient::PinkRed => "to-red-500",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn class_text(&self) -> &str {
|
||||
pub fn class_text(&self) -> &'static str {
|
||||
match self {
|
||||
Gradient::PinkOrange => "text-pink-500",
|
||||
Gradient::PinkOrange => "text-rose-400",
|
||||
Gradient::CyanBlue => "text-cyan-500",
|
||||
Gradient::TealLime => "text-teal-300",
|
||||
Gradient::PurplePink => "text-purple-500",
|
||||
Gradient::TealLime => "text-emerald-300",
|
||||
Gradient::PurplePink => "text-fuchsia-500",
|
||||
Gradient::PurpleBlue => "text-purple-600",
|
||||
Gradient::Orange => "text-orange-400",
|
||||
Gradient::Pink => "text-pink-400",
|
||||
Gradient::PinkRed => "text-pink-400",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::gradient::Gradient;
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use std::collections::BTreeMap;
|
||||
@ -217,6 +218,14 @@ impl Attempt {
|
||||
Attempt::Flash => Icon::BoltSolid,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gradient(&self) -> Gradient {
|
||||
match self {
|
||||
Attempt::Attempt => Gradient::PinkOrange,
|
||||
Attempt::Send => Gradient::TealLime,
|
||||
Attempt::Flash => Gradient::CyanBlue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for Problem {
|
||||
|
@ -8,6 +8,7 @@ use crate::gradient::Gradient;
|
||||
use crate::models;
|
||||
use crate::models::HoldRole;
|
||||
use crate::server_functions;
|
||||
use crate::server_functions::SetIsFavorite;
|
||||
use leptos::Params;
|
||||
use leptos::prelude::*;
|
||||
use leptos_router::params::Params;
|
||||
@ -71,9 +72,7 @@ struct Context {
|
||||
cb_next_problem: Callback<()>,
|
||||
cb_set_problem: Callback<models::Problem>,
|
||||
cb_upsert_todays_attempt: Callback<server_functions::UpsertTodaysAttempt>,
|
||||
|
||||
#[expect(dead_code)]
|
||||
filtered_problems: Signal<BTreeSet<models::Problem>>,
|
||||
cb_set_is_favorite: Callback<bool>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
@ -99,9 +98,6 @@ fn Controller(
|
||||
let user_interaction = signals::user_interaction(user_interactions.into(), problem.into());
|
||||
let problem_transformations = signals::problem_transformations(wall);
|
||||
|
||||
// TODO: still used?
|
||||
let filtered_problems = signals::filtered_problems(wall, filter_holds.into());
|
||||
|
||||
let filtered_problem_transformations = signals::filtered_problem_transformations(problem_transformations.into(), filter_holds.into());
|
||||
let todays_attempt = signals::todays_attempt(user_interaction);
|
||||
let latest_attempt = signals::latest_attempt(user_interaction);
|
||||
@ -112,6 +108,20 @@ fn Controller(
|
||||
upsert_todays_attempt.dispatch(RonEncoded(attempt));
|
||||
});
|
||||
|
||||
// Set favorite
|
||||
let set_is_favorite = ServerAction::<RonEncoded<server_functions::SetIsFavorite>>::new();
|
||||
let cb_set_is_favorite = Callback::new(move |is_favorite| {
|
||||
let wall_uid = wall.read().uid;
|
||||
let Some(problem) = problem.get() else {
|
||||
return;
|
||||
};
|
||||
set_is_favorite.dispatch(RonEncoded(SetIsFavorite {
|
||||
wall_uid,
|
||||
problem,
|
||||
is_favorite,
|
||||
}));
|
||||
});
|
||||
|
||||
// Callback: Set specific problem
|
||||
let cb_set_problem: Callback<models::Problem> = Callback::new(move |problem| {
|
||||
set_problem.set(Some(problem));
|
||||
@ -166,6 +176,16 @@ fn Controller(
|
||||
}
|
||||
});
|
||||
|
||||
// Update user interactions after setting favorite
|
||||
Effect::new(move || {
|
||||
if let Some(Ok(v)) = set_is_favorite.value().get() {
|
||||
let v = v.into_inner();
|
||||
user_interactions.update(|map| {
|
||||
map.insert(v.problem.clone(), v);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
provide_context(Context {
|
||||
wall,
|
||||
problem: problem.into(),
|
||||
@ -176,9 +196,9 @@ fn Controller(
|
||||
cb_remove_hold_from_filter,
|
||||
cb_next_problem: cb_set_random_problem,
|
||||
cb_set_problem,
|
||||
cb_set_is_favorite,
|
||||
todays_attempt,
|
||||
filter_holds: filter_holds.into(),
|
||||
filtered_problems: filtered_problems.into(),
|
||||
filtered_problem_transformations: filtered_problem_transformations.into(),
|
||||
user_interactions: user_interactions.into(),
|
||||
});
|
||||
@ -214,8 +234,10 @@ fn View() -> impl IntoView {
|
||||
|
||||
<Section title="Current problem">
|
||||
{move || ctx.problem.get().map(|problem| view! { <ProblemInfo problem /> })}
|
||||
<Separator /> <Transformations /> <Separator /><AttemptRadioGroup />
|
||||
<Separator /> <History />
|
||||
<Separator /> <div class="flex flex-row gap-2 justify-between">
|
||||
<Transformations />
|
||||
<FavoriteButton />
|
||||
</div> <Separator /> <AttemptRadioGroup /> <Separator /> <History />
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@ -296,6 +318,24 @@ fn HoldsButton() -> impl IntoView {
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn FavoriteButton() -> impl IntoView {
|
||||
crate::tracing::on_enter!();
|
||||
|
||||
let ctx = use_context::<Context>().unwrap();
|
||||
|
||||
let ui_toggle = Signal::derive(move || {
|
||||
let guard = ctx.user_interaction.read();
|
||||
guard.as_ref().map(|user_interaction| user_interaction.is_favorite).unwrap_or(false)
|
||||
});
|
||||
let on_click = Callback::new(move |_| {
|
||||
ctx.cb_set_is_favorite.run(!ui_toggle.get());
|
||||
});
|
||||
let icon = Signal::derive(move || if ui_toggle.get() { Icon::Heart } else { Icon::HeartOutline });
|
||||
view! { <Button icon on_click color=Gradient::PurplePink highlight=ui_toggle /> }
|
||||
}
|
||||
|
||||
#[component]
|
||||
#[tracing::instrument(skip_all)]
|
||||
fn NextProblemButton() -> impl IntoView {
|
||||
@ -635,6 +675,7 @@ mod signals {
|
||||
})
|
||||
}
|
||||
|
||||
#[expect(dead_code)]
|
||||
pub(crate) fn filtered_problems(
|
||||
wall: Signal<models::Wall>,
|
||||
filter_holds: Signal<BTreeSet<models::HoldPosition>>,
|
||||
|
@ -244,3 +244,62 @@ pub(crate) async fn upsert_todays_attempt(
|
||||
.map_err(ServerFnError::new)
|
||||
.map(RonEncoded::new)
|
||||
}
|
||||
|
||||
/// Sets is_favorite field for a problem
|
||||
#[server(
|
||||
input = Ron,
|
||||
output = Ron,
|
||||
custom = RonEncoded
|
||||
)]
|
||||
#[tracing::instrument(err(Debug))]
|
||||
pub(crate) async fn set_is_favorite(
|
||||
wall_uid: models::WallUid,
|
||||
problem: models::Problem,
|
||||
is_favorite: bool,
|
||||
) -> Result<RonEncoded<models::UserInteraction>, ServerFnError> {
|
||||
use crate::server::db::Database;
|
||||
use crate::server::db::DatabaseOperationError;
|
||||
use leptos::prelude::expect_context;
|
||||
|
||||
tracing::trace!("Enter");
|
||||
|
||||
#[derive(Debug, Error, Display, From)]
|
||||
enum Error {
|
||||
#[display("Wall not found: {_0:?}")]
|
||||
WallNotFound(#[error(not(source))] models::WallUid),
|
||||
|
||||
DatabaseOperation(DatabaseOperationError),
|
||||
}
|
||||
|
||||
async fn inner(wall_uid: models::WallUid, problem: models::Problem, is_favorite: bool) -> Result<UserInteraction, Error> {
|
||||
let db = expect_context::<Database>();
|
||||
|
||||
let user_interaction = db
|
||||
.write(|txn| {
|
||||
let mut user_table = txn.open_table(crate::server::db::current::TABLE_USER)?;
|
||||
|
||||
let key = (wall_uid, problem.clone());
|
||||
|
||||
// Pop or default
|
||||
let mut user_interaction = user_table
|
||||
.remove(&key)?
|
||||
.map(|guard| guard.value())
|
||||
.unwrap_or_else(|| models::UserInteraction::new(wall_uid, problem));
|
||||
|
||||
user_interaction.is_favorite = is_favorite;
|
||||
|
||||
user_table.insert(key, user_interaction.clone())?;
|
||||
|
||||
Ok(user_interaction)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(user_interaction)
|
||||
}
|
||||
|
||||
inner(wall_uid, problem, is_favorite)
|
||||
.await
|
||||
.map_err(error_reporter::Report::new)
|
||||
.map_err(ServerFnError::new)
|
||||
.map(RonEncoded::new)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user